From ebb529257e7daf4de085febb8577146fb5d2ed6c Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:56:09 -0700 Subject: [PATCH] fix(agent): avoid duplicate context preparation (#4129) --- src/openhuman/agent/harness/fork_context.rs | 34 ++++ src/openhuman/agent/harness/mod.rs | 5 +- .../agent/harness/session/turn/core.rs | 161 +++++++++++++++--- .../tools/agent_prepare_context.rs | 105 +++++++++++- 4 files changed, 282 insertions(+), 23 deletions(-) diff --git a/src/openhuman/agent/harness/fork_context.rs b/src/openhuman/agent/harness/fork_context.rs index f82e080be..db2b09eb7 100644 --- a/src/openhuman/agent/harness/fork_context.rs +++ b/src/openhuman/agent/harness/fork_context.rs @@ -139,11 +139,23 @@ pub struct ParentExecutionContext { pub run_queue: Option>, } +/// A context-preparation source that already ran for the current parent turn. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentContextPreparedSource { + pub source: String, + pub has_enough_context: Option, +} + tokio::task_local! { /// Parent execution context, scoped per agent turn. `None` for any /// tool invocation that happens outside an agent turn (e.g. CLI/RPC /// direct tool calls); `spawn_subagent` rejects in that case. pub static PARENT_CONTEXT: ParentExecutionContext; + + /// Context-preparation sources that already ran for this parent turn. + /// Tools such as `agent_prepare_context` use this to avoid spawning a + /// second context scout after the harness has already prepared context. + pub static AGENT_CONTEXT_PREPARED_SOURCES: Arc>; } /// Returns a clone of the current parent execution context, if one is set. @@ -161,3 +173,25 @@ where { PARENT_CONTEXT.scope(ctx, future).await } + +/// Returns the one-shot context-preparation sources that have already run for +/// the current parent turn. +pub fn current_agent_context_prepared_sources() -> Vec { + AGENT_CONTEXT_PREPARED_SOURCES + .try_with(|sources| sources.as_ref().clone()) + .unwrap_or_default() +} + +/// Run `future` with the current turn's already-prepared context sources +/// installed. +pub async fn with_agent_context_prepared_sources( + sources: Vec, + future: F, +) -> R +where + F: std::future::Future, +{ + AGENT_CONTEXT_PREPARED_SOURCES + .scope(Arc::new(sources), future) + .await +} diff --git a/src/openhuman/agent/harness/mod.rs b/src/openhuman/agent/harness/mod.rs index 8385998da..fb9ef20e4 100644 --- a/src/openhuman/agent/harness/mod.rs +++ b/src/openhuman/agent/harness/mod.rs @@ -53,7 +53,10 @@ pub use definition::{ AgentDefinition, AgentDefinitionRegistry, DefinitionSource, ModelSpec, PromptSource, SandboxMode, ToolScope, TriggerMemoryAgent, }; -pub use fork_context::{current_parent, with_parent_context, ParentExecutionContext}; +pub use fork_context::{ + current_agent_context_prepared_sources, current_parent, with_agent_context_prepared_sources, + with_parent_context, AgentContextPreparedSource, ParentExecutionContext, +}; pub use interrupt::{check_interrupt, InterruptFence, InterruptedError}; pub use model_vision_context::{current_model_vision, with_current_model_vision}; pub use sandbox_context::{current_sandbox_mode, with_current_sandbox_mode}; diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index 8b4dd55cf..6a02c18cc 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -1,6 +1,5 @@ //! Core turn execution: the main `turn()` method and `inject_agent_experience_context()`. -use super::super::transcript; use super::super::turn_engine_adapter::{AgentCheckpoint, AgentObserver, AgentToolSource}; use super::super::types::Agent; use super::{ @@ -22,7 +21,6 @@ use crate::openhuman::util::truncate_with_ellipsis; use anyhow::Result; use std::hash::{Hash, Hasher}; -use std::sync::Arc; /// Decide whether the harness-driven "super context" collection pass should /// run this turn. @@ -59,6 +57,40 @@ fn should_run_super_context( is_orchestrator && first_turn && !has_prior_conversation && enabled } +fn parse_context_bundle_has_enough_context(bundle: &str) -> Option { + const PREFIX: &str = "has_enough_context:"; + let line = bundle.lines().map(str::trim).find(|line| { + line.get(..PREFIX.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(PREFIX)) + })?; + let value = line[PREFIX.len()..].trim(); + if value.eq_ignore_ascii_case("true") { + Some(true) + } else if value.eq_ignore_ascii_case("false") { + Some(false) + } else { + None + } +} + +fn render_agent_context_status_note(sources: &[harness::AgentContextPreparedSource]) -> String { + let sources = if sources.is_empty() { + "the OpenHuman harness".to_string() + } else { + sources + .iter() + .map(|source| source.source.as_str()) + .collect::>() + .join(", ") + }; + format!( + "## Agent context status\n\nAgent context retrieval/preparation has already run once \ + for this turn in code via {sources}. Do not call `agent_prepare_context` again for \ + general context preparation. Use the prepared context below, and call only specific \ + follow-up tools if a concrete missing detail is required." + ) +} + impl Agent { /// Executes a single interaction "turn" with the agent. /// @@ -510,9 +542,17 @@ impl Agent { let mut parent_context = self.build_parent_execution_context(); parent_context.model_name = effective_model.clone(); - let enriched = self + let mut agent_context_prepared_sources: Vec = + Vec::new(); + let (enriched, memory_agent_context_injected) = self .inject_triggered_memory_agent_context(user_message, enriched, &parent_context) .await; + if memory_agent_context_injected { + agent_context_prepared_sources.push(harness::AgentContextPreparedSource { + source: "memory agent context retrieval".to_string(), + has_enough_context: None, + }); + } // ── "Super context": harness-driven first-turn context collection ── // When enabled (config `context.super_context_enabled`, surfaced as the @@ -520,8 +560,9 @@ impl Agent { // orchestrator LLM gets the turn, and fold its bounded // `[context_bundle]` into the user message. This is the harness driving // the collection deterministically — unlike the `agent_prepare_context` - // tool, which the model chooses to call. The tool stays exposed so the - // model can still scout again mid-turn. + // tool, which the model chooses to call. If this path succeeds, the + // turn prompt and task-local marker tell `agent_prepare_context` not + // to run another generic scout pass in the same turn. // // Gate on the **first turn of a genuinely new thread**: `first_turn` // (empty `history`) is necessary but NOT sufficient, because a thread @@ -568,6 +609,10 @@ impl Agent { match scout { Ok(result) if !result.is_error => { let bundle = result.output(); + agent_context_prepared_sources.push(harness::AgentContextPreparedSource { + source: "super context preparation".to_string(), + has_enough_context: parse_context_bundle_has_enough_context(&bundle), + }); log::info!( "[agent_loop] super_context bundle collected bundle_chars={}", bundle.chars().count() @@ -575,7 +620,8 @@ impl Agent { format!( "## Prepared context (super context)\n\nThe following context was \ collected up-front by a read-only context scout before this turn. \ - Use it to ground your response; you may still gather more if needed.\n\n\ + Use it to ground your response; do not call `agent_prepare_context` \ + again for general preparation.\n\n\ {bundle}\n\n---\n\n{enriched}" ) } @@ -597,6 +643,19 @@ impl Agent { enriched }; + let enriched = if agent_context_prepared_sources.is_empty() { + enriched + } else { + log::debug!( + "[agent_loop] agent context already prepared sources={:?}", + agent_context_prepared_sources + ); + format!( + "{}\n\n{enriched}", + render_agent_context_status_note(&agent_context_prepared_sources) + ) + }; + // #3602: stamp every turn's user message with the live local time // so time-relative phrasing (greetings, "today"/"tonight") is // grounded on the real clock. Rides the user message — not the @@ -874,11 +933,24 @@ impl Agent { } } let result = if turn_stop_hooks.is_empty() { - harness::with_parent_context(parent_context, turn_body).await + harness::with_parent_context( + parent_context, + harness::with_agent_context_prepared_sources( + agent_context_prepared_sources.clone(), + turn_body, + ), + ) + .await } else { harness::with_parent_context( parent_context, - crate::openhuman::agent::stop_hooks::with_stop_hooks(turn_stop_hooks, turn_body), + harness::with_agent_context_prepared_sources( + agent_context_prepared_sources.clone(), + crate::openhuman::agent::stop_hooks::with_stop_hooks( + turn_stop_hooks, + turn_body, + ), + ), ) .await }; @@ -975,7 +1047,7 @@ impl Agent { user_message: &str, enriched: String, parent_context: &ParentExecutionContext, - ) -> String { + ) -> (String, bool) { const MEMORY_AGENT_ID: &str = "agent_memory"; const MAX_MEMORY_AGENT_BLOCK_CHARS: usize = 8000; @@ -985,25 +1057,25 @@ impl Agent { self.agent_definition_id, self.trigger_memory_agent ); - return enriched; + return (enriched, false); } if self.agent_definition_id == MEMORY_AGENT_ID { log::debug!("[agent_memory:trigger] skipped recursive memory agent invocation"); - return enriched; + return (enriched, false); } let Some(registry) = harness::AgentDefinitionRegistry::global() else { log::warn!( "[agent_memory:trigger] AgentDefinitionRegistry unavailable; continuing without memory agent context" ); - return enriched; + return (enriched, false); }; let Some(definition) = registry.get(MEMORY_AGENT_ID).cloned() else { log::warn!( "[agent_memory:trigger] `{MEMORY_AGENT_ID}` definition unavailable; continuing without memory agent context" ); - return enriched; + return (enriched, false); }; let task_id = format!("mem-trigger-{}", uuid::Uuid::new_v4()); @@ -1054,12 +1126,15 @@ impl Agent { } output = truncate_with_ellipsis(&output, MAX_MEMORY_AGENT_BLOCK_CHARS); if output.trim().is_empty() { - return enriched; + return (enriched, false); } - format!( - "## Memory agent context\n\n{}\n\n---\n\n{}", - output.trim(), - enriched + ( + format!( + "## Memory agent context\n\n{}\n\n---\n\n{}", + output.trim(), + enriched + ), + true, ) } Err(err) => { @@ -1068,7 +1143,7 @@ impl Agent { self.agent_definition_id, task_id ); - enriched + (enriched, false) } } } @@ -1076,7 +1151,11 @@ impl Agent { #[cfg(test)] mod super_context_gate_tests { - use super::should_run_super_context; + use super::{ + parse_context_bundle_has_enough_context, render_agent_context_status_note, + should_run_super_context, + }; + use crate::openhuman::agent::harness::AgentContextPreparedSource; #[test] fn runs_only_on_first_turn_of_a_new_orchestrator_thread_when_enabled() { @@ -1121,4 +1200,46 @@ mod super_context_gate_tests { // super context must only run for the user-facing orchestrator. assert!(!should_run_super_context(false, true, false, true)); } + + #[test] + fn context_status_note_tells_model_not_to_prepare_context_again() { + let note = render_agent_context_status_note(&[ + AgentContextPreparedSource { + source: "memory agent context retrieval".to_string(), + has_enough_context: None, + }, + AgentContextPreparedSource { + source: "super context preparation".to_string(), + has_enough_context: Some(true), + }, + ]); + + assert!(note.contains("## Agent context status")); + assert!(note.contains("already run once")); + assert!(note.contains("memory agent context retrieval")); + assert!(note.contains("super context preparation")); + assert!(note.contains("Do not call `agent_prepare_context` again")); + } + + #[test] + fn parses_context_bundle_sufficiency() { + assert_eq!( + parse_context_bundle_has_enough_context( + "[context_bundle]\nhas_enough_context: true\n[/context_bundle]" + ), + Some(true) + ); + assert_eq!( + parse_context_bundle_has_enough_context( + "[context_bundle]\nHAS_ENOUGH_CONTEXT: false\n[/context_bundle]" + ), + Some(false) + ); + assert_eq!( + parse_context_bundle_has_enough_context( + "[context_bundle]\nsummary: ok\n[/context_bundle]" + ), + None + ); + } } diff --git a/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs b/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs index 040da206a..6e22cdf3c 100644 --- a/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs +++ b/src/openhuman/agent_orchestration/tools/agent_prepare_context.rs @@ -13,7 +13,9 @@ use crate::core::event_bus::{publish_global, DomainEvent}; use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; -use crate::openhuman::agent::harness::fork_context::current_parent; +use crate::openhuman::agent::harness::fork_context::{ + current_agent_context_prepared_sources, current_parent, AgentContextPreparedSource, +}; use crate::openhuman::agent::harness::subagent_runner::{ run_subagent, SubagentRunOptions, SubagentRunStatus, }; @@ -52,6 +54,40 @@ fn is_well_formed_context_bundle(output: &str) -> bool { && trimmed.len() >= OPEN.len() + CLOSE.len() } +fn already_prepared_context_bundle(sources: &[AgentContextPreparedSource]) -> String { + let source_names = if sources.is_empty() { + "the OpenHuman harness".to_string() + } else { + sources + .iter() + .map(|source| source.source.as_str()) + .collect::>() + .join(", ") + }; + let has_enough_context = if sources + .iter() + .any(|source| source.has_enough_context == Some(false)) + { + false + } else { + sources + .iter() + .any(|source| source.has_enough_context == Some(true)) + }; + let sufficiency_note = if has_enough_context { + "The earlier prepared context reported enough context." + } else { + "This no-op result does not assert that enough context is available; \ + inspect the earlier prepared-context blocks for sufficiency and recommended follow-up tools." + }; + format!( + "[context_bundle]\nhas_enough_context: {has_enough_context}\nproposed_goal: none\n\ + summary: Agent context has already been prepared once for this turn by {source_names}. \ + {sufficiency_note} Use the existing prepared-context blocks in the current user message; do not run \ + another context_scout pass.\nrecommended_tool_calls:\n[/context_bundle]" + ) +} + /// Run the `context_scout` sub-agent inline (blocking) for `question` and /// return its bounded `[context_bundle]` envelope as a [`ToolResult`]. /// @@ -493,7 +529,9 @@ impl Tool for AgentPrepareContextTool { integrations, and the web, then returns whether there's enough context \ to answer, a compact context summary, an ordered list of recommended \ next tool calls (your own tools, by exact name, with args), and any \ - skills worth running. Use at the start of non-trivial turns." + skills worth running. Use at the start of non-trivial turns unless the \ + current prompt says agent context has already been prepared; in that \ + case, use the prepared context and do not call this tool again." } fn parameters_schema(&self) -> serde_json::Value { @@ -524,6 +562,18 @@ impl Tool for AgentPrepareContextTool { } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let prepared_sources = current_agent_context_prepared_sources(); + if !prepared_sources.is_empty() { + tracing::info!( + target: "agent_prepare_context", + sources = ?prepared_sources, + "[agent_prepare_context] skipped because agent context is already prepared for this turn" + ); + return Ok(ToolResult::success(already_prepared_context_bundle( + &prepared_sources, + ))); + } + let question = args.get("question").and_then(|v| v.as_str()).unwrap_or(""); let focus = args.get("focus").and_then(|v| v.as_str()); run_context_scout(question, focus).await @@ -552,6 +602,15 @@ mod tests { assert!(props.get("focus").is_some()); } + #[test] + fn description_skips_when_context_is_already_prepared() { + let tool = AgentPrepareContextTool::new(); + let description = tool.description(); + + assert!(description.contains("agent context has already been prepared")); + assert!(description.contains("do not call this tool again")); + } + #[test] fn build_scout_prompt_includes_request_focus_and_catalog() { let prompt = AgentPrepareContextTool::build_scout_prompt( @@ -617,6 +676,48 @@ mod tests { assert!(result.output().contains("question")); } + #[tokio::test] + async fn execute_short_circuits_when_context_already_prepared() { + let tool = AgentPrepareContextTool::new(); + let result = crate::openhuman::agent::harness::with_agent_context_prepared_sources( + vec![AgentContextPreparedSource { + source: "super context preparation".to_string(), + has_enough_context: Some(false), + }], + tool.execute(json!({"question": "prepare context again"})), + ) + .await + .unwrap(); + + assert!(!result.is_error, "{}", result.output()); + assert!(result.output().contains("[context_bundle]")); + assert!(result.output().contains("has_enough_context: false")); + assert!(result.output().contains("already been prepared once")); + assert!(result.output().contains("super context preparation")); + assert!(result + .output() + .contains("does not assert that enough context is available")); + assert!(result.output().contains("[/context_bundle]")); + } + + #[tokio::test] + async fn execute_preserves_prior_prepared_context_sufficiency_when_true() { + let tool = AgentPrepareContextTool::new(); + let result = crate::openhuman::agent::harness::with_agent_context_prepared_sources( + vec![AgentContextPreparedSource { + source: "super context preparation".to_string(), + has_enough_context: Some(true), + }], + tool.execute(json!({"question": "prepare context again"})), + ) + .await + .unwrap(); + + assert!(!result.is_error, "{}", result.output()); + assert!(result.output().contains("has_enough_context: true")); + assert!(result.output().contains("reported enough context")); + } + #[test] fn accepts_a_single_well_formed_bundle() { let out = "[context_bundle]\nhas_enough_context: true\nsummary: ok\n[/context_bundle]";