diff --git a/src/openhuman/agent/harness/session/turn/context.rs b/src/openhuman/agent/harness/session/turn/context.rs index 9281bb440b..e6b53c180b 100644 --- a/src/openhuman/agent/harness/session/turn/context.rs +++ b/src/openhuman/agent/harness/session/turn/context.rs @@ -335,10 +335,93 @@ impl Agent { // Route through the global context manager so every // prompt-building call-site — main agent, sub-agent runner, // channel runtimes — shares one builder configuration. - let mut prompt = self.context.build_system_prompt(&ctx)?; - if let Some(boundary) = render_tool_policy_boundary(&self.tool_policy_session, 2048) { - prompt = format!("{boundary}\n\n{prompt}"); - } - Ok(prompt) + let prompt = self.context.build_system_prompt(&ctx)?; + // Appended, not prepended (#5704). Every line of this block is + // session-scoped — agent id, channel, entry point, risk level, the + // allowed-tool list — so putting it first moves the prompt's first + // diverging byte to offset 0 and costs the inference backend's + // automatic prefix cache everything behind it. That is the same + // concern that keeps DateTimeSection out of `for_subagent` and keeps + // the connected-server overview sorted. The model reads the whole + // system message either way. + // + // It also keeps the archetype/persona as the prompt's opening line, + // which the prepend had replaced with a constant heading for every + // agent. + let boundary = render_tool_policy_boundary(&self.tool_policy_session, 2048); + Ok(append_tool_policy_boundary(prompt, boundary)) + } +} + +/// Place the tool-policy boundary block relative to the assembled prompt. +/// +/// Separated from [`Agent`] so the ordering can be tested without standing up a +/// session: everything that decides the placement is in these two arguments. +fn append_tool_policy_boundary(prompt: String, boundary: Option) -> String { + match boundary { + Some(boundary) => format!("{prompt}\n\n{boundary}"), + None => prompt, + } +} + +#[cfg(test)] +mod tool_policy_boundary_placement_tests { + use super::append_tool_policy_boundary; + + const PERSONA: &str = "You are the archetype.\nMore persona."; + const BOUNDARY: &str = "## Tool Policy Boundary\n- Agent: alpha"; + + #[test] + fn the_boundary_goes_after_the_prompt_body() { + let out = append_tool_policy_boundary(PERSONA.into(), Some(BOUNDARY.into())); + let body_at = out.find("You are the archetype.").expect("body present"); + let boundary_at = out + .find("## Tool Policy Boundary") + .expect("boundary present"); + assert!( + body_at < boundary_at, + "the session-scoped block must not precede the stable prompt (#5704):\n{out}" + ); + } + + #[test] + fn the_persona_stays_the_opening_line() { + let out = append_tool_policy_boundary(PERSONA.into(), Some(BOUNDARY.into())); + assert_eq!( + out.lines().next(), + Some("You are the archetype."), + "prepending replaced every agent's first line with a constant heading" + ); + } + + #[test] + fn two_agents_share_the_whole_prompt_body_as_a_common_prefix() { + // The point of appending: the varying part is last, so everything the + // two turns have in common is a shared leading prefix the backend can + // reuse. Prepending moved the first diverging byte to offset 0. + let alpha = append_tool_policy_boundary( + PERSONA.into(), + Some("## Tool Policy Boundary\n- Agent: alpha".into()), + ); + let beta = append_tool_policy_boundary( + PERSONA.into(), + Some("## Tool Policy Boundary\n- Agent: beta".into()), + ); + let shared = alpha + .bytes() + .zip(beta.bytes()) + .take_while(|(a, b)| a == b) + .count(); + assert!( + shared >= PERSONA.len(), + "the shared prefix ({shared} bytes) must cover the whole stable body ({} bytes)", + PERSONA.len() + ); + } + + #[test] + fn no_boundary_leaves_the_prompt_untouched() { + let out = append_tool_policy_boundary(PERSONA.into(), None); + assert_eq!(out, PERSONA); } }