fix(agent): keep context prep out of orchestrator scope (#4141)

This commit is contained in:
Steven Enamakel
2026-06-25 21:43:08 -07:00
committed by GitHub
parent c1d4aa61fc
commit 2652db773e
8 changed files with 94 additions and 112 deletions
+46 -41
View File
@@ -285,7 +285,7 @@ fn assert_no_jsonrpc_error<'a>(v: &'a Value, context: &str) -> &'a Value {
.unwrap_or_else(|| panic!("{context}: missing result: {v}"))
}
fn write_min_config(openhuman_dir: &Path, api_origin: &str) {
fn write_min_config(openhuman_dir: &Path, api_origin: &str, super_context_enabled: bool) {
let cfg = format!(
r#"api_url = "{api_origin}"
default_model = "e2e-mock-model"
@@ -296,10 +296,11 @@ chat_onboarding_completed = true
encrypt = false
[context]
# These harness tests script the mock-LLM call sequence exactly; the default-on
# Most harness tests script the mock-LLM call sequence exactly; the default-on
# first-turn "super context" pass (#4085) would spawn a context_scout and consume
# a scripted response, desyncing the orchestrator turns. Disable it here.
super_context_enabled = false
# a scripted response, desyncing the orchestrator turns. Tests opt in only when
# they explicitly cover super context.
super_context_enabled = {super_context_enabled}
"#
);
fn write_config_file(config_dir: &Path, cfg: &str) {
@@ -447,6 +448,10 @@ impl Drop for Stack {
}
async fn boot_stack() -> Stack {
boot_stack_with_super_context(false).await
}
async fn boot_stack_with_super_context(super_context_enabled: bool) -> Stack {
// Ensure the global AgentDefinitionRegistry is populated with built-in
// archetypes (orchestrator, researcher, task_manager_agent, etc.) before
// the RPC stack starts. Without this the session builder cannot synthesise
@@ -465,9 +470,13 @@ async fn boot_stack() -> Stack {
let (mock_addr, mock_join) = serve_on_ephemeral(scripted_upstream_router()).await;
let mock_origin = format!("http://{mock_addr}");
write_min_config(&openhuman_home, &mock_origin);
write_min_config(&openhuman_home, &mock_origin, super_context_enabled);
// Pre-write user-scoped config so it's found after auth_store_session activates "e2e-user".
write_min_config(&openhuman_home.join("users").join("e2e-user"), &mock_origin);
write_min_config(
&openhuman_home.join("users").join("e2e-user"),
&mock_origin,
super_context_enabled,
);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{rpc_addr}");
@@ -803,33 +812,28 @@ async fn subagent_delegation_happy_path_inner() {
stack.shutdown();
}
// ─── agent_prepare_context: context-scout happy path ──────────────────────────
// ─── Super context: harness-driven context-scout happy path ───────────────────
//
// Tool surface (src/openhuman/agent_orchestration/tools/agent_prepare_context.rs,
// src/openhuman/agent_registry/agents/context_scout/agent.toml):
// - `agent_prepare_context` is a DIRECT tool on the orchestrator's [tools].named
// list (not a delegate). It runs the read-only `context_scout` sub-agent
// inline and returns the scout's `[context_bundle]` envelope as the tool
// result, which the orchestrator then synthesizes from.
// - First-turn context prep is harness-driven, not orchestrator-scoped. The
// harness runs the read-only `context_scout` before the orchestrator's first
// LLM call and injects the scout's `[context_bundle]` into the user message.
//
// Actual LLM request ordering (with registry init):
// request[0] = orchestrator → model returns { tool_calls: [agent_prepare_context(...)] }
// request[1] = context_scout subagent inner loop → model returns the bundle text
// request[2] = orchestrator synthesis → final text, having read the bundle
// request[0] = context_scout subagent inner loop → model returns the bundle text
// request[1] = orchestrator synthesis → final text, having read the bundle
/// Orchestrator calls `agent_prepare_context`; the `context_scout` subagent runs
/// its own inner LLM call and returns a `[context_bundle]`; the final
/// orchestrator synthesis reads it. Three upstream requests prove the full
/// scout path ran, and the synthesis request carries the bundle as a tool result.
/// The harness runs `context_scout` before the first orchestrator turn and
/// injects the returned `[context_bundle]`; the final orchestrator synthesis
/// reads it. Two upstream requests prove the full scout path ran without
/// exposing `agent_prepare_context` to the orchestrator.
#[test]
fn agent_prepare_context_happy_path() {
run_on_agent_stack(
"agent_prepare_context_happy_path",
agent_prepare_context_happy_path_inner,
);
fn super_context_happy_path() {
run_on_agent_stack("super_context_happy_path", super_context_happy_path_inner);
}
async fn agent_prepare_context_happy_path_inner() {
async fn super_context_happy_path_inner() {
let _lock = env_lock();
let scout_bundle = "[context_bundle]\n\
has_enough_context: true\n\
@@ -840,17 +844,12 @@ async fn agent_prepare_context_happy_path_inner() {
\x20 why: execute the prepared plan\n\
[/context_bundle]";
reset_script(vec![
// request[0]: Orchestrator calls the direct `agent_prepare_context` tool.
tool_call_completion(
"agent_prepare_context",
json!({ "question": "find the marker phrase" }),
),
// request[1]: context_scout subagent inner LLM call returns the bundle.
// request[0]: harness-driven context_scout subagent returns the bundle.
text_completion(scout_bundle),
// request[2]: Orchestrator reads the bundle and synthesizes.
// request[1]: Orchestrator reads the injected bundle and synthesizes.
text_completion("Prepared. CTX_CANARY_7 noted; next I'd spawn a worker."),
]);
let stack = boot_stack().await;
let stack = boot_stack_with_super_context(true).await;
let mut events = spawn_sse_collector(format!(
"{}/events?client_id=harness-prepctx",
@@ -869,7 +868,7 @@ async fn agent_prepare_context_happy_path_inner() {
assert_eq!(
done.get("event").and_then(Value::as_str),
Some("chat_done"),
"expected chat_done for agent_prepare_context: {done}"
"expected chat_done for super_context: {done}"
);
let full_response = done
.get("full_response")
@@ -882,21 +881,27 @@ async fn agent_prepare_context_happy_path_inner() {
let requests = with_captured(|c| c.clone());
assert!(
requests.len() >= 3,
"expected ≥3 upstream requests (orchestrator + context_scout + synthesis), got {};\n{}",
requests.len() >= 2,
"expected ≥2 upstream requests (context_scout + orchestrator), got {};\n{}",
requests.len(),
serde_json::to_string_pretty(&requests).unwrap_or_default()
);
let all_serialized = serde_json::to_string(&requests).unwrap_or_default();
let orchestrator_tools = serde_json::to_string(
requests
.last()
.and_then(|r| r.pointer("/body/tools"))
.unwrap_or(&Value::Null),
)
.unwrap_or_default();
assert!(
!all_serialized.contains("Unknown tool:"),
"found 'Unknown tool:' — agent_prepare_context was not registered/visible; requests: {}",
serde_json::to_string_pretty(&requests).unwrap_or_default()
!orchestrator_tools.contains("agent_prepare_context"),
"orchestrator should not see agent_prepare_context in its tool schema; tools: {orchestrator_tools}"
);
// The synthesis turn must carry the scout's bundle back as a tool result
// proves the [context_bundle] flowed into the orchestrator's context.
// The synthesis turn must carry the scout's bundle in the user message
// proves the [context_bundle] flowed into the orchestrator's context without
// an orchestrator tool call.
let last_messages = serde_json::to_string(
requests
.last()