feat(agent): agent_prepare_context tool + context_scout subagent (#3949)

This commit is contained in:
Steven Enamakel
2026-06-23 10:22:38 -07:00
committed by GitHub
parent 8cf289a904
commit 4c2277070f
42 changed files with 1729 additions and 1 deletions
@@ -277,6 +277,7 @@ fn parent_context(workspace: &Path, provider: Arc<ScriptedProvider>) -> ParentEx
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "round21-parent-model".to_string(),
temperature: 0.0,
workspace_dir: workspace.to_path_buf(),
+127
View File
@@ -795,6 +795,133 @@ async fn subagent_delegation_happy_path_inner() {
stack.shutdown();
}
// ─── agent_prepare_context: 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.
//
// 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
/// 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.
#[test]
fn agent_prepare_context_happy_path() {
run_on_agent_stack(
"agent_prepare_context_happy_path",
agent_prepare_context_happy_path_inner,
);
}
async fn agent_prepare_context_happy_path_inner() {
let _lock = env_lock();
let scout_bundle = "[context_bundle]\n\
has_enough_context: true\n\
summary: CTX_CANARY_7 — the user wants the marker phrase (memory).\n\
recommended_tool_calls:\n\
\x20 - tool: spawn_worker_thread\n\
\x20 args: {\"prompt\": \"act on the marker\"}\n\
\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.
text_completion(scout_bundle),
// request[2]: Orchestrator reads the bundle and synthesizes.
text_completion("Prepared. CTX_CANARY_7 noted; next I'd spawn a worker."),
]);
let stack = boot_stack().await;
let mut events = spawn_sse_collector(format!(
"{}/events?client_id=harness-prepctx",
stack.rpc_base
));
send_web_chat(
&stack.rpc_base,
400,
"harness-prepctx",
"thread-prepctx",
"prepare context for the marker",
)
.await;
let done = wait_for_terminal(&mut events, Duration::from_secs(120)).await;
assert_eq!(
done.get("event").and_then(Value::as_str),
Some("chat_done"),
"expected chat_done for agent_prepare_context: {done}"
);
let full_response = done
.get("full_response")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("chat_done missing 'full_response': {done}"));
assert!(
full_response.contains("CTX_CANARY_7"),
"final response missing scout canary; full_response: {full_response}\nevent: {done}"
);
let requests = with_captured(|c| c.clone());
assert!(
requests.len() >= 3,
"expected ≥3 upstream requests (orchestrator + context_scout + synthesis), got {};\n{}",
requests.len(),
serde_json::to_string_pretty(&requests).unwrap_or_default()
);
let all_serialized = serde_json::to_string(&requests).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()
);
// The synthesis turn must carry the scout's bundle back as a tool result —
// proves the [context_bundle] flowed into the orchestrator's context.
let last_messages = serde_json::to_string(
requests
.last()
.and_then(|r| r.pointer("/body/messages"))
.unwrap_or(&Value::Null),
)
.unwrap_or_default();
assert!(
last_messages.contains("[context_bundle]") && last_messages.contains("CTX_CANARY_7"),
"synthesis request missing the scout bundle as a tool result; messages: {last_messages}"
);
// request[1] (context_scout) must build a different context than request[0]
// (orchestrator) — proves a genuinely separate scout agent ran.
let req0_sys = requests
.first()
.and_then(|r| r.pointer("/body/messages/0/content"))
.and_then(Value::as_str)
.unwrap_or("");
let req1_sys = requests
.get(1)
.and_then(|r| r.pointer("/body/messages/0/content"))
.and_then(Value::as_str)
.unwrap_or("");
assert_ne!(
req0_sys, req1_sys,
"request[0] and request[1] share identical first-message content — \
context_scout did not build its own context"
);
stack.shutdown();
}
// ─── Task 4: Subagent clarification flow ──────────────────────────────────────
//
// Exercises the ask_user_clarification path via scheduler_agent
@@ -385,6 +385,7 @@ fn parent_context(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> Parent
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "round19-parent".to_string(),
temperature: 0.0,
workspace_dir: workspace,
+1
View File
@@ -133,6 +133,7 @@ fn stub_parent_context() -> ParentExecutionContext {
provider: Arc::new(StubProvider),
all_tools: Arc::new(vec![]),
all_tool_specs: Arc::new(vec![]),
visible_tool_names: std::collections::HashSet::new(),
model_name: "stub-model".into(),
temperature: 0.4,
workspace_dir: std::path::PathBuf::from("/tmp"),
+1
View File
@@ -277,6 +277,7 @@ fn parent_context(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> Parent
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(tool_specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "coverage-model".to_string(),
temperature: 0.0,
workspace_dir: workspace,
@@ -317,6 +317,7 @@ fn parent(workspace_dir: PathBuf, provider: Arc<ScriptedProvider>) -> ParentExec
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "round25-parent-model".to_string(),
temperature: 0.0,
workspace_dir,
@@ -283,6 +283,7 @@ fn parent(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> ParentExecutio
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "round18-model".to_string(),
temperature: 0.0,
workspace_dir: workspace,
@@ -900,6 +900,7 @@ async fn subagent_runner_parent_context_filters_tools_caps_output_and_reports_er
provider: provider.clone(),
all_tools: Arc::new(all_tools),
all_tool_specs: Arc::new(all_specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "parent-model".to_string(),
temperature: 0.22,
workspace_dir: workspace_path.clone(),
+1
View File
@@ -167,6 +167,7 @@ async fn test_integrations_agent_has_current_date_context() -> Result<()> {
provider: provider.clone(),
all_tools: Arc::new(vec![Box::new(MockCalendarTool)]),
all_tool_specs: Arc::new(vec![MockCalendarTool.spec()]),
visible_tool_names: std::collections::HashSet::new(),
model_name: "test-model".into(),
temperature: 0.4,
workspace_dir: std::env::temp_dir(),
@@ -343,6 +343,7 @@ async fn drive_subagent() {
provider: provider.clone(),
all_tools: Arc::new(vec![]),
all_tool_specs: Arc::new(vec![]),
visible_tool_names: std::collections::HashSet::new(),
model_name: "test-model".into(),
temperature: 0.4,
workspace_dir: std::env::temp_dir(),
@@ -361,6 +361,7 @@ fn parent_context(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> Parent
provider,
all_tools: Arc::new(tools),
all_tool_specs: Arc::new(tool_specs),
visible_tool_names: std::collections::HashSet::new(),
model_name: "round16-model".to_string(),
temperature: 0.0,
workspace_dir: workspace,