mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(agent): avoid duplicate context preparation (#4129)
This commit is contained in:
@@ -139,11 +139,23 @@ pub struct ParentExecutionContext {
|
||||
pub run_queue: Option<Arc<crate::openhuman::agent::harness::run_queue::RunQueue>>,
|
||||
}
|
||||
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
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<Vec<AgentContextPreparedSource>>;
|
||||
}
|
||||
|
||||
/// 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<AgentContextPreparedSource> {
|
||||
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<F, R>(
|
||||
sources: Vec<AgentContextPreparedSource>,
|
||||
future: F,
|
||||
) -> R
|
||||
where
|
||||
F: std::future::Future<Output = R>,
|
||||
{
|
||||
AGENT_CONTEXT_PREPARED_SOURCES
|
||||
.scope(Arc::new(sources), future)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<bool> {
|
||||
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::<Vec<_>>()
|
||||
.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<harness::AgentContextPreparedSource> =
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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<ToolResult> {
|
||||
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]";
|
||||
|
||||
Reference in New Issue
Block a user