agent_graph: LangGraph-style state-machine harness — checkpointing, HITL, observability (#4249) (#4261)

This commit is contained in:
Steven Enamakel
2026-07-01 00:27:39 -07:00
committed by GitHub
parent fb536ecef2
commit 7a67f6ca35
253 changed files with 11431 additions and 31553 deletions
-20
View File
@@ -3742,26 +3742,6 @@ mod tests {
);
}
#[test]
fn context_prefix_too_large_error_display_classifies_as_expected() {
// S3.5.d coupling test: the pre-dispatch actionable error's Display
// string MUST classify as the suppressed ContextWindowExceeded bucket,
// so a wording drift in the user-facing message (which is what gets
// re-raised and re-reported up the stack) fails CI instead of silently
// leaking the event to Sentry.
let err = crate::openhuman::agent::harness::token_budget::ContextPrefixTooLargeError {
prefix_tokens: 10_978,
context_window: 8_192,
max_input_tokens: 7_372,
};
assert_eq!(
expected_error_kind(&err.to_string()),
Some(ExpectedErrorKind::ContextWindowExceeded),
"ContextPrefixTooLargeError Display must stay coupled to the \
context-window-exceeded classifier (drift would leak Sentry events)"
);
}
#[test]
fn does_not_classify_unrelated_messages_as_context_window_exceeded() {
// Anchors are context-overflow specific. A generic "window" or
+19 -22
View File
@@ -2,7 +2,9 @@
//!
//! The agent domain publishes one native request handler, `agent.run_turn`,
//! which executes a single end-to-end agentic turn (LLM call → tool calls →
//! loop until final text) using the full `run_tool_call_loop` machinery.
//! loop until final text) on the tinyagents harness via
//! [`run_channel_turn_via_graph`](crate::openhuman::agent::harness::run_channel_turn_via_graph)
//! (issue #4249; the legacy `run_tool_call_loop` was removed).
//!
//! Consumers call it via [`crate::core::event_bus::request_native_global`]
//! with an [`AgentTurnRequest`] and receive an [`AgentTurnResponse`]. The
@@ -31,7 +33,7 @@ use crate::openhuman::prompt_injection::{
use crate::openhuman::tools::Tool;
use super::harness::definition::{AgentDefinitionRegistry, SandboxMode};
use super::harness::{run_tool_call_loop, with_current_sandbox_mode};
use super::harness::{run_channel_turn_via_graph, with_current_sandbox_mode};
use crate::openhuman::file_state::with_file_state_agent_id;
/// Method name used to dispatch an agentic turn through the native bus.
@@ -301,31 +303,26 @@ pub fn register_agent_handlers() {
with_file_state_agent_id(
file_state_id,
with_current_sandbox_mode(sandbox_mode, async {
run_tool_call_loop(
provider.as_ref(),
// Channel/CLI turns run through the tinyagents harness
// (issue #4249); the legacy `run_tool_call_loop` is removed.
// `on_progress` mirrors the harness event stream (tool
// timeline, text deltas, cost footer) — production channel
// dispatch always supplies it and now expects it live.
// `on_delta` (raw Sender<String>) is superseded by
// `on_progress` text deltas, so it's intentionally unused.
let _ = (&provider_name, silent, &channel_name, on_delta);
run_channel_turn_via_graph(
provider.clone(),
&mut history,
tools_registry.as_ref(),
&provider_name,
tools_registry.clone(),
extra_tools,
visible_tool_names.as_ref(),
&model,
temperature,
silent,
&channel_name,
&multimodal,
&multimodal_files,
max_tool_iterations,
on_delta,
visible_tool_names.as_ref(),
&extra_tools,
multimodal.clone(),
multimodal_files.clone(),
on_progress,
// Bus path runs ad-hoc agent turns without an Agent
// handle, so we pass None — payload summarization is
// wired into the orchestrator session via Agent::turn,
// not the bus dispatcher.
None,
// Use the default (allow-all) tool policy. Custom
// policies can be wired in via AgentTurnRequest when
// per-channel policy configuration is added (#2134).
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
}),
+128
View File
@@ -0,0 +1,128 @@
//! Per-agent turn-graph selection (issue #4249).
//!
//! Each built-in agent folder ships a `graph.rs` exporting
//! `pub fn graph() -> AgentGraph`, mirroring the per-agent `prompt.rs::build`
//! hook. The registry loader injects the returned value onto the agent's
//! [`AgentDefinition`] (post-deserialize, exactly like `PromptSource::Dynamic`),
//! and the sub-agent turn chokepoint (`run_typed_mode`) consults it:
//!
//! - [`AgentGraph::Default`] runs the shared default sub-agent turn graph
//! (`subagent_runner::ops::graph::run_subagent_via_graph`).
//! - [`AgentGraph::Custom`] hands the assembled turn to the agent's own graph
//! runner — a bespoke tinyagents graph, thin over
//! `run_turn_via_tinyagents_shared`.
//!
//! Today every built-in agent selects `Default`. The hook is the extension
//! point that lets a specialized agent (orchestrator, researcher, …) define a
//! bespoke graph without branching the shared runner.
use std::collections::HashSet;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::mpsc::Sender;
use crate::openhuman::agent::harness::run_queue::RunQueue;
use crate::openhuman::agent::harness::subagent_runner::SubagentRunError;
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::inference::provider::{ChatMessage, Provider};
use crate::openhuman::tools::{Tool, ToolSpec};
/// The assembled inputs for one sub-agent turn, handed to a custom
/// [`AgentGraph::Custom`] runner.
///
/// Owned (history + tools by value) so the runner can be a boxed `'static`
/// future without borrowing the caller's stack — mirrors the positional
/// arguments the default `run_subagent_via_graph` takes.
pub struct AgentTurnRequest {
pub provider: Arc<dyn Provider>,
pub model: String,
pub temperature: f64,
/// Full working transcript for the turn (system + prior + this user turn).
pub history: Vec<ChatMessage>,
pub parent_tools: Arc<Vec<Box<dyn Tool>>>,
pub dynamic_tools: Vec<Box<dyn Tool>>,
pub specs: Vec<ToolSpec>,
pub allowed_names: HashSet<String>,
pub max_iterations: usize,
pub run_queue: Option<Arc<RunQueue>>,
pub on_progress: Option<Sender<AgentProgress>>,
pub agent_id: String,
pub task_id: String,
pub extended_policy: bool,
pub worker_thread_id: Option<String>,
pub workspace_dir: PathBuf,
pub max_output_tokens: u32,
pub model_vision: bool,
}
/// Token/cost totals a custom runner reports back. Mirrors the runner's internal
/// `AggregatedUsage` without coupling to its (private) type.
#[derive(Debug, Clone, Copy, Default)]
pub struct AgentTurnUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_input_tokens: u64,
pub charged_amount_usd: f64,
}
/// The result of a custom turn graph. `history` is the full updated transcript
/// (the runner persists it back and mirrors it to any worker thread).
pub struct AgentTurnResult {
pub history: Vec<ChatMessage>,
pub output: String,
pub iterations: usize,
pub usage: AgentTurnUsage,
/// Set when an early-exit tool (e.g. `ask_user_clarification`) paused the run.
pub early_exit_tool: Option<String>,
/// `true` when the run stopped at the model-call cap with work still pending.
pub hit_cap: bool,
}
/// A per-agent custom turn-graph runner: given the assembled [`AgentTurnRequest`],
/// drive a bespoke tinyagents graph and return the [`AgentTurnResult`].
pub type AgentGraphRunner =
fn(
AgentTurnRequest,
) -> Pin<Box<dyn Future<Output = Result<AgentTurnResult, SubagentRunError>> + Send>>;
/// How an agent's turn is driven. Selected per-agent via each folder's
/// `graph.rs::graph()` and injected onto [`AgentDefinition`][super::definition::AgentDefinition].
#[derive(Clone)]
pub enum AgentGraph {
/// Run the shared default sub-agent turn graph (`run_subagent_via_graph`).
Default,
/// Run this agent's bespoke graph.
Custom(AgentGraphRunner),
}
impl Default for AgentGraph {
fn default() -> Self {
AgentGraph::Default
}
}
impl AgentGraph {
/// Build a custom graph selection from a runner fn. Sugar for
/// [`AgentGraph::Custom`] so a folder's `graph.rs` reads
/// `AgentGraph::custom(run)`.
pub fn custom(run: AgentGraphRunner) -> Self {
AgentGraph::Custom(run)
}
/// `true` when this agent uses the shared default graph.
pub fn is_default(&self) -> bool {
matches!(self, AgentGraph::Default)
}
}
impl std::fmt::Debug for AgentGraph {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AgentGraph::Default => f.write_str("Default"),
AgentGraph::Custom(_) => f.write_str("Custom(<fn>)"),
}
}
}
@@ -272,7 +272,7 @@ impl ArchivistHook {
tracing::debug!(
"[archivist] rolling_segment_recap: only heuristic bookend stub \
available (no real LLM recap) session={session_id} segment={} — \
returning None so compaction falls back to ProviderSummarizer",
returning None",
open_segment.segment_id
);
return None;
@@ -1,579 +0,0 @@
//! Targeted bug-hunt tests for the agent harness + tool dispatch.
//!
//! These pair the [`super::test_support::KeywordScriptedProvider`] with
//! tightly-scoped tools to probe corner cases that aren't covered by
//! the broader behavioural suite. Each test documents the behaviour
//! observed, and any tests prefixed `documents_` describe a quirk
//! worth flagging in code review (silent data loss, surprising
//! precedence rules, etc.) rather than asserting correctness.
use super::test_support::{KeywordRule, KeywordScriptedProvider, ScriptedToolCall};
use super::tool_loop::run_tool_call_loop;
use crate::openhuman::inference::provider::{ChatMessage, ChatResponse, ToolCall};
use crate::openhuman::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use parking_lot::Mutex;
use serde_json::json;
use std::sync::Arc;
fn mm() -> crate::openhuman::config::MultimodalConfig {
crate::openhuman::config::MultimodalConfig::default()
}
fn mff() -> crate::openhuman::config::MultimodalFileConfig {
crate::openhuman::config::MultimodalFileConfig::default()
}
struct ArgsCapturingTool {
name_str: String,
captured: Arc<Mutex<Vec<serde_json::Value>>>,
output: String,
}
impl ArgsCapturingTool {
fn new(name: &str, output: &str) -> (Self, Arc<Mutex<Vec<serde_json::Value>>>) {
let captured = Arc::new(Mutex::new(Vec::new()));
(
Self {
name_str: name.to_string(),
captured: captured.clone(),
output: output.to_string(),
},
captured,
)
}
}
#[async_trait]
impl Tool for ArgsCapturingTool {
fn name(&self) -> &str {
&self.name_str
}
fn description(&self) -> &str {
"captures args"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({"type":"object","additionalProperties":true})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.captured.lock().push(args);
Ok(ToolResult::success(self.output.clone()))
}
}
// ── 1. Native tool call with a JSON-encoded string of args ────────
//
// Real OpenAI/Anthropic providers send `arguments` as a *string*
// containing JSON. The harness must transparently decode it before
// passing to the tool.
#[tokio::test]
async fn native_tool_call_decodes_json_encoded_arguments_string() {
let provider =
KeywordScriptedProvider::new(vec![KeywordRule::final_reply("captured-ok", "done")])
.with_native_tools(true);
// Forced first turn: native tool_call with arguments as a STRING.
provider.push_forced_response(ChatResponse {
text: None,
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "captured".into(),
arguments: "{\"city\":\"Berlin\",\"n\":3}".to_string(),
extra_content: None,
}],
usage: None,
reasoning_content: None,
});
let (tool, captured) = ArgsCapturingTool::new("captured", "captured-ok");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(tool)];
let mut history = vec![ChatMessage::user("anything")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
"channel",
&mm(),
&mff(),
3,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.unwrap();
assert_eq!(out, "done");
let args = captured.lock();
assert_eq!(args.len(), 1);
assert_eq!(args[0]["city"], "Berlin");
assert_eq!(args[0]["n"], 3);
}
// ── 2. SILENT FAILURE: non-JSON args string is replaced with `{}` ──
//
// `parse_arguments_value` calls `serde_json::from_str` on the string
// payload and silently falls back to `{}` on parse failure. This
// means: if a model emits `arguments: "world"` (not valid JSON), the
// tool sees `{}` — the user's intent is silently dropped and there's
// no signal to the LLM that anything went wrong.
//
// This test documents the behaviour so future refactors don't
// "accidentally" fix it without considering downstream impact, and
// flags the behaviour for follow-up.
#[tokio::test]
async fn documents_silent_drop_of_non_json_arguments_string() {
let provider =
KeywordScriptedProvider::new(vec![KeywordRule::final_reply("captured-ok", "done")])
.with_native_tools(true);
provider.push_forced_response(ChatResponse {
text: None,
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "captured".into(),
// Not valid JSON — the model "meant" a plain string.
arguments: "world".to_string(),
extra_content: None,
}],
usage: None,
reasoning_content: None,
});
let (tool, captured) = ArgsCapturingTool::new("captured", "captured-ok");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(tool)];
let mut history = vec![ChatMessage::user("hi")];
run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
"channel",
&mm(),
&mff(),
3,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.unwrap();
let args = captured.lock();
assert_eq!(args.len(), 1);
// BUG-CANDIDATE: the LLM's intent ("world") is silently dropped.
// The tool receives an empty object with no indication the args
// were unparseable. A more defensive design would surface a
// structured error back to the model instead.
assert_eq!(args[0], json!({}));
}
// ── 3. Parallel tool calls in a single iteration ──────────────────
//
// The model may emit multiple `<tool_call>` blocks at once. They
// should all execute in order, each result threaded into history.
#[tokio::test]
async fn parallel_tool_calls_in_single_iteration_all_execute() {
let provider =
KeywordScriptedProvider::new(vec![KeywordRule::final_reply("tool_b-ok", "all done")]);
// Both tool calls share one assistant turn (XML path).
provider.push_forced_response(ChatResponse {
text: Some(
"<tool_call>{\"name\":\"tool_a\",\"arguments\":{\"k\":1}}</tool_call>\n\
<tool_call>{\"name\":\"tool_b\",\"arguments\":{\"k\":2}}</tool_call>"
.into(),
),
tool_calls: vec![],
usage: None,
reasoning_content: None,
});
let (a, a_calls) = ArgsCapturingTool::new("tool_a", "tool_a-ok");
let (b, b_calls) = ArgsCapturingTool::new("tool_b", "tool_b-ok");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(a), Box::new(b)];
let mut history = vec![ChatMessage::user("do both")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
"channel",
&mm(),
&mff(),
5,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.unwrap();
assert_eq!(out, "all done");
assert_eq!(a_calls.lock().len(), 1);
assert_eq!(b_calls.lock().len(), 1);
assert_eq!(a_calls.lock()[0]["k"], 1);
assert_eq!(b_calls.lock()[0]["k"], 2);
}
// ── 4. Same-named tools: first match in registry wins ─────────────
#[tokio::test]
async fn same_named_tool_in_registry_first_match_wins() {
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call("go", ScriptedToolCall::new("dupe", json!({}))),
KeywordRule::final_reply("first-output", "got first"),
]);
let (first, first_calls) = ArgsCapturingTool::new("dupe", "first-output");
let (second, second_calls) = ArgsCapturingTool::new("dupe", "second-output");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(first), Box::new(second)];
let mut history = vec![ChatMessage::user("go ahead")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
"channel",
&mm(),
&mff(),
5,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.unwrap();
assert_eq!(out, "got first");
assert_eq!(first_calls.lock().len(), 1);
assert_eq!(second_calls.lock().len(), 0);
}
// ── 5. Markdown-fenced tool call (```tool_call ... ```) ───────────
//
// Some OpenRouter-mediated models emit fenced markdown blocks
// instead of XML tags. `parse_tool_calls` is supposed to handle this.
#[tokio::test]
async fn markdown_fenced_tool_call_block_is_parsed() {
let provider =
KeywordScriptedProvider::new(vec![KeywordRule::final_reply("tool_a-ok", "ok done")]);
provider.push_forced_response(ChatResponse {
text: Some(
"Here's the call:\n\
```tool_call\n\
{\"name\":\"tool_a\",\"arguments\":{\"x\":42}}\n\
```"
.into(),
),
tool_calls: vec![],
usage: None,
reasoning_content: None,
});
let (a, a_calls) = ArgsCapturingTool::new("tool_a", "tool_a-ok");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(a)];
let mut history = vec![ChatMessage::user("anything")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
"channel",
&mm(),
&mff(),
5,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.unwrap();
assert_eq!(out, "ok done");
assert_eq!(a_calls.lock().len(), 1);
assert_eq!(a_calls.lock()[0]["x"], 42);
}
// ── 6. Native vs prompt-guided precedence ─────────────────────────
//
// When a response carries BOTH native `tool_calls` *and* an XML
// `<tool_call>` block in the text, the native calls are authoritative
// and the XML must NOT also fire (else the same logical call could
// execute twice).
#[tokio::test]
async fn native_tool_calls_take_precedence_over_xml_in_text() {
let provider =
KeywordScriptedProvider::new(vec![KeywordRule::final_reply("tool_a-ok", "done")])
.with_native_tools(true);
provider.push_forced_response(ChatResponse {
text: Some("<tool_call>{\"name\":\"tool_a\",\"arguments\":{}}</tool_call>".into()),
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "tool_a".into(),
arguments: "{\"src\":\"native\"}".into(),
extra_content: None,
}],
usage: None,
reasoning_content: None,
});
let (a, a_calls) = ArgsCapturingTool::new("tool_a", "tool_a-ok");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(a)];
let mut history = vec![ChatMessage::user("call it")];
run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
"channel",
&mm(),
&mff(),
5,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.unwrap();
// Tool ran exactly once, using the native args (not the XML block).
assert_eq!(a_calls.lock().len(), 1);
assert_eq!(a_calls.lock()[0]["src"], "native");
}
// ── 7. Big tool output: per-tool cap truncation ───────────────────
struct CappedBigTool;
#[async_trait]
impl Tool for CappedBigTool {
fn name(&self) -> &str {
"cap_big"
}
fn description(&self) -> &str {
"emits a big payload but caps it"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({"type":"object"})
}
fn max_result_size_chars(&self) -> Option<usize> {
Some(50)
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult::success("X".repeat(500)))
}
}
#[tokio::test]
async fn per_tool_max_result_size_caps_history_payload() {
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call("go", ScriptedToolCall::new("cap_big", json!({}))),
KeywordRule::final_reply("truncated by tool cap", "ok"),
]);
let tools: Vec<Box<dyn Tool>> = vec![Box::new(CappedBigTool)];
let mut history = vec![ChatMessage::user("go big")];
run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
"channel",
&mm(),
&mff(),
5,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.unwrap();
let tool_results = history
.iter()
.find(|msg| msg.role == "user" && msg.content.contains("[Tool results]"))
.expect("tool results should land in history");
assert!(
tool_results.content.contains("truncated by tool cap"),
"cap marker missing: {}",
tool_results.content
);
assert!(
tool_results.content.len() < 500,
"raw 500-char body must not flow through (got {} chars)",
tool_results.content.len()
);
}
// ── 8. Empty assistant response with no tool calls terminates loop
#[tokio::test]
async fn empty_response_with_no_tool_calls_terminates_with_empty_text() {
let provider = KeywordScriptedProvider::new(vec![]);
provider.push_forced_response(ChatResponse {
text: Some(String::new()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
});
let tools: Vec<Box<dyn Tool>> = vec![];
let mut history = vec![ChatMessage::user("hi")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
"channel",
&mm(),
&mff(),
5,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.unwrap();
assert!(out.is_empty());
// Loop must still record the (empty) assistant turn.
assert!(history.iter().any(|m| m.role == "assistant"));
}
// ── 9. Progress sink receives ordered turn lifecycle events ───────
#[tokio::test]
async fn progress_sink_emits_lifecycle_events_in_order() {
use crate::openhuman::agent::progress::AgentProgress;
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call("go", ScriptedToolCall::new("p_tool", json!({}))),
KeywordRule::final_reply("p_tool-ok", "all done"),
]);
let (tool, _) = ArgsCapturingTool::new("p_tool", "p_tool-ok");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(tool)];
let (tx, mut rx) = tokio::sync::mpsc::channel::<AgentProgress>(32);
let mut history = vec![ChatMessage::user("go go")];
run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
"channel",
&mm(),
&mff(),
5,
None,
None,
&[],
Some(tx),
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.unwrap();
let mut events = Vec::new();
while let Ok(e) = rx.try_recv() {
events.push(e);
}
// Must see TurnStarted, at least 2 IterationStarted, ToolCallStarted,
// ToolCallCompleted, and TurnCompleted.
let kinds: Vec<&'static str> = events
.iter()
.map(|e| match e {
AgentProgress::TurnStarted => "TurnStarted",
AgentProgress::IterationStarted { .. } => "IterationStarted",
AgentProgress::ToolCallStarted { .. } => "ToolCallStarted",
AgentProgress::ToolCallCompleted { .. } => "ToolCallCompleted",
AgentProgress::TurnCompleted { .. } => "TurnCompleted",
_ => "Other",
})
.collect();
assert_eq!(kinds.first().copied(), Some("TurnStarted"));
assert_eq!(kinds.last().copied(), Some("TurnCompleted"));
assert!(kinds.contains(&"IterationStarted"));
assert!(kinds.contains(&"ToolCallStarted"));
assert!(kinds.contains(&"ToolCallCompleted"));
// ToolCallStarted must precede its matching ToolCallCompleted.
let started = kinds.iter().position(|k| *k == "ToolCallStarted").unwrap();
let completed = kinds
.iter()
.position(|k| *k == "ToolCallCompleted")
.unwrap();
assert!(started < completed);
}
@@ -83,6 +83,7 @@ pub(crate) fn test_main_def() -> AgentDefinition {
delegate_name: None,
agent_tier: AgentTier::Chat,
source: DefinitionSource::Builtin,
graph: Default::default(),
}
}
@@ -126,6 +127,7 @@ pub(crate) fn test_inherit_echo_def() -> AgentDefinition {
delegate_name: None,
agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker,
source: DefinitionSource::Builtin,
graph: Default::default(),
}
}
@@ -165,6 +167,7 @@ pub(crate) fn test_inherit_parallel_worker_def() -> AgentDefinition {
delegate_name: None,
agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker,
source: DefinitionSource::Builtin,
graph: Default::default(),
}
}
+19 -5
View File
@@ -27,6 +27,13 @@ use std::path::PathBuf;
use crate::openhuman::tokenjuice::AgentTokenjuiceCompression;
/// Iteration ceiling for an [`IterationPolicy::Extended`] agent — the higher
/// bound a long-running agent (orchestrator, deep research) is allowed to reach
/// before the harness stops it. Lives here, the sole consumer, since the legacy
/// `tool_loop` that originally defined it was removed in the tinyagents
/// migration (issue #4249).
pub const EXTENDED_MAX_TOOL_ITERATIONS: usize = 50;
/// Iteration-cap policy for a sub-agent.
///
/// Controls how the harness enforces [`AgentDefinition::max_iterations`]:
@@ -36,7 +43,7 @@ use crate::openhuman::tokenjuice::AgentTokenjuiceCompression;
/// the cap signals a likely loop.
/// * **Extended** — the per-agent `max_iterations` is replaced at runtime
/// by a higher harness-wide constant
/// ([`EXTENDED_MAX_TOOL_ITERATIONS`](super::tool_loop::EXTENDED_MAX_TOOL_ITERATIONS))
/// ([`EXTENDED_MAX_TOOL_ITERATIONS`])
/// so the agent can complete realistic multi-tool workflows. The
/// repeated-failure circuit breaker and cost budget still apply. The
/// UI omits the denominator ("step N" instead of "turn N/M") to avoid
@@ -276,6 +283,15 @@ pub struct AgentDefinition {
/// Tracks where the definition was loaded from (Builtin vs. File).
#[serde(skip)]
pub source: DefinitionSource,
// ── turn graph ──────────────────────────────────────────────────────
/// How this agent's turn is driven (issue #4249). Injected post-load from
/// the agent folder's `graph.rs::graph()` (mirrors how
/// [`PromptSource::Dynamic`] is injected from `prompt.rs::build`); TOML-
/// authored agents cannot set it, so it is `#[serde(skip)]` and defaults to
/// [`AgentGraph::Default`] (the shared default turn graph).
#[serde(skip, default)]
pub graph: super::agent_graph::AgentGraph,
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -457,13 +473,11 @@ impl AgentDefinition {
///
/// * `Strict` → `self.max_iterations` unchanged.
/// * `Extended` → the higher of `self.max_iterations` and the
/// harness-wide [`EXTENDED_MAX_TOOL_ITERATIONS`](super::tool_loop::EXTENDED_MAX_TOOL_ITERATIONS).
/// harness-wide [`EXTENDED_MAX_TOOL_ITERATIONS`].
pub fn effective_max_iterations(&self) -> usize {
match self.iteration_policy {
IterationPolicy::Strict => self.max_iterations,
IterationPolicy::Extended => self
.max_iterations
.max(super::tool_loop::EXTENDED_MAX_TOOL_ITERATIONS),
IterationPolicy::Extended => self.max_iterations.max(EXTENDED_MAX_TOOL_ITERATIONS),
}
}
@@ -31,6 +31,7 @@ fn make_def(id: &str) -> AgentDefinition {
delegate_name: None,
agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker,
source: DefinitionSource::Builtin,
graph: Default::default(),
}
}
@@ -237,7 +238,7 @@ fn extended_policy_raises_cap_to_at_least_extended_constant() {
def.iteration_policy = IterationPolicy::Extended;
assert_eq!(
def.effective_max_iterations(),
super::super::tool_loop::EXTENDED_MAX_TOOL_ITERATIONS
super::EXTENDED_MAX_TOOL_ITERATIONS
);
assert!(def.effective_max_iterations() > def.max_iterations);
}
@@ -268,7 +269,7 @@ iteration_policy = "extended"
assert_eq!(def.iteration_policy, IterationPolicy::Extended);
assert_eq!(
def.effective_max_iterations(),
super::super::tool_loop::EXTENDED_MAX_TOOL_ITERATIONS
super::EXTENDED_MAX_TOOL_ITERATIONS
);
}
@@ -32,20 +32,3 @@ pub(crate) trait CheckpointStrategy: Send + Sync {
/// `tool → outcome` summary of the run so far.
async fn on_max_iter(&self, digest: &str, max_iterations: usize) -> Result<CheckpointOutcome>;
}
/// Surface the cap as the typed [`AgentError::MaxIterationsExceeded`], boxed
/// through `anyhow::Error`, so downstream wrappers — notably
/// `Agent::run_single` — can downcast and suppress Sentry emission for this
/// deterministic agent-state outcome (OPENHUMAN-TAURI-99 / -98).
pub(crate) struct ErrorCheckpoint;
#[async_trait]
impl CheckpointStrategy for ErrorCheckpoint {
async fn on_max_iter(&self, _digest: &str, max_iterations: usize) -> Result<CheckpointOutcome> {
Err(anyhow::Error::new(
crate::openhuman::agent::error::AgentError::MaxIterationsExceeded {
max: max_iterations,
},
))
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -26
View File
@@ -1,31 +1,14 @@
//! Unified agent turn engine.
//! Shared agent-turn seams reused by the tinyagents harness route.
//!
//! Historically the harness carried THREE near-identical agentic loops — one
//! per entry point (`Agent::turn` for web/desktop chat, `run_tool_call_loop`
//! for non-web channels + triage, and the subagent `run_inner_loop`). They each
//! re-implemented the same shape (call the LLM → parse tool calls → execute
//! tools → append results → repeat until final text or the iteration cap) and
//! had drifted in subtle ways.
//!
//! This module is the single home for the pieces those loops share, so they
//! can't drift again. The extraction is incremental (see the unify-agent-turn
//! plan): the first piece to land is [`tools::run_one_tool`] — the per-call
//! tool executor (policy gate → scope guard → approval gate → execute with
//! timeout → scrub/tokenjuice/cap/summarize → audit), which was previously
//! duplicated verbatim across all three loops.
//! The harness historically carried three near-identical agentic loops
//! (`Agent::turn`, `run_tool_call_loop`, the sub-agent `run_inner_loop`), all
//! retired in favour of the tinyagents harness (issue #4249). What survives here
//! are the cross-cutting pieces the tinyagents route still reuses: the
//! max-iteration [`CheckpointStrategy`] seam and the [`ProgressReporter`] /
//! [`TurnProgress`] sink that mirrors a turn's events onto `AgentProgress`.
pub(crate) mod checkpoint;
pub(crate) mod core;
pub(crate) mod parser;
pub(crate) mod progress;
pub(crate) mod state;
pub(crate) mod tool_source;
pub(crate) mod tools;
pub(crate) use checkpoint::{CheckpointOutcome, CheckpointStrategy, ErrorCheckpoint};
pub(crate) use core::{run_turn_engine, TurnStop};
pub(crate) use parser::{DefaultParser, DispatcherParser};
pub(crate) use progress::{ProgressReporter, SubagentProgress, TurnProgress};
pub(crate) use state::{NullObserver, TurnObserver};
pub(crate) use tool_source::{RegistryToolSource, ToolSource};
pub(crate) use tools::{run_one_tool, ToolRunResult};
pub(crate) use checkpoint::{CheckpointOutcome, CheckpointStrategy};
pub(crate) use progress::{ProgressReporter, TurnProgress};
@@ -1,70 +0,0 @@
//! Response-parsing seam.
//!
//! The channel loop and subagent extract tool calls from a provider response
//! with the built-in native-first + XML-fallback logic ([`DefaultParser`]).
//! `Agent::turn` instead uses its configured [`ToolDispatcher`] (native / XML /
//! PFormat) — PFormat in particular parses positional `name[args]` calls the
//! built-in path can't. [`DispatcherParser`] adapts a dispatcher to this seam so
//! the engine stays parser-agnostic while preserving every dispatcher's grammar.
//!
//! `parse` returns `(display_text, calls)`: the narrative text to surface (tool
//! markup stripped) and the parsed calls in the engine's internal
//! [`ParsedToolCall`] shape. The engine keeps the *raw* response text
//! separately for assistant-history serialization.
use crate::openhuman::agent::dispatcher::ToolDispatcher;
use crate::openhuman::agent::harness::parse::{
parse_structured_tool_calls, parse_tool_calls, ParsedToolCall,
};
use crate::openhuman::inference::provider::ChatResponse;
pub(crate) trait ResponseParser: Send + Sync {
/// Returns `(display_text, calls)` for this provider response.
fn parse(&self, resp: &ChatResponse) -> (String, Vec<ParsedToolCall>);
}
/// Built-in parser: prefer native structured tool calls, fall back to the
/// XML-tag parser over the response text. Used by the channel loop + subagent.
pub(crate) struct DefaultParser;
impl ResponseParser for DefaultParser {
fn parse(&self, resp: &ChatResponse) -> (String, Vec<ParsedToolCall>) {
let response_text = resp.text_or_empty().to_string();
let mut calls = parse_structured_tool_calls(&resp.tool_calls);
let mut parsed_text = String::new();
if calls.is_empty() {
let (fallback_text, fallback_calls) = parse_tool_calls(&response_text);
if !fallback_text.is_empty() {
parsed_text = fallback_text;
}
calls = fallback_calls;
}
let display_text = if parsed_text.is_empty() {
response_text
} else {
parsed_text
};
(display_text, calls)
}
}
/// Adapts an [`Agent`]'s configured [`ToolDispatcher`] to the parser seam,
/// converting the dispatcher's `ParsedToolCall` shape into the engine's.
pub(crate) struct DispatcherParser<'a> {
pub dispatcher: &'a dyn ToolDispatcher,
}
impl ResponseParser for DispatcherParser<'_> {
fn parse(&self, resp: &ChatResponse) -> (String, Vec<ParsedToolCall>) {
let (text, calls) = self.dispatcher.parse_response(resp);
let calls = calls
.into_iter()
.map(|c| ParsedToolCall {
name: c.name,
arguments: c.arguments,
id: c.tool_call_id,
})
.collect();
(text, calls)
}
}
@@ -199,133 +199,6 @@ impl ProgressReporter for TurnProgress {
}
}
/// Sub-agent flavor: `Subagent*` lifecycle + `SubagentToolCall*`, no streaming.
pub(crate) struct SubagentProgress {
pub sink: Option<tokio::sync::mpsc::Sender<AgentProgress>>,
pub agent_id: String,
pub task_id: String,
pub extended_policy: bool,
}
#[async_trait]
impl ProgressReporter for SubagentProgress {
async fn iteration_started(&self, iteration: u32, max_iterations: u32) {
if let Some(ref sink) = self.sink {
emit(
sink,
AgentProgress::SubagentIterationStarted {
agent_id: self.agent_id.clone(),
task_id: self.task_id.clone(),
iteration,
max_iterations,
extended_policy: self.extended_policy,
},
);
}
}
#[allow(clippy::too_many_arguments)]
async fn tool_started(
&self,
call_id: &str,
tool_name: &str,
arguments: &serde_json::Value,
iteration: u32,
display_label: Option<&str>,
display_detail: Option<&str>,
) {
if let Some(ref sink) = self.sink {
emit(
sink,
AgentProgress::SubagentToolCallStarted {
agent_id: self.agent_id.clone(),
task_id: self.task_id.clone(),
call_id: call_id.to_string(),
tool_name: tool_name.to_string(),
arguments: arguments.clone(),
iteration,
display_label: display_label.map(str::to_string),
display_detail: display_detail.map(str::to_string),
},
);
}
}
async fn tool_completed(
&self,
call_id: &str,
tool_name: &str,
success: bool,
output: &str,
elapsed_ms: u64,
iteration: u32,
) {
if let Some(ref sink) = self.sink {
emit(
sink,
AgentProgress::SubagentToolCallCompleted {
agent_id: self.agent_id.clone(),
task_id: self.task_id.clone(),
call_id: call_id.to_string(),
tool_name: tool_name.to_string(),
success,
output_chars: output.chars().count(),
output: output.to_string(),
elapsed_ms,
iteration,
},
);
}
}
/// Stream the child's visible text + reasoning deltas to the parent,
/// attributed to this sub-agent's `task_id` so the UI renders them inside
/// the live subagent row (PR #3007). Tool-call arg fragments are dropped
/// here — they're already surfaced via the `SubagentToolCall*` lifecycle
/// events, so forwarding them too would double-render.
fn make_stream_sink(
&self,
iteration: u32,
) -> (
Option<tokio::sync::mpsc::Sender<ProviderDelta>>,
Option<tokio::task::JoinHandle<()>>,
) {
let Some(sink) = self.sink.clone() else {
return (None, None);
};
let agent_id = self.agent_id.clone();
let task_id = self.task_id.clone();
let (tx, mut rx) = tokio::sync::mpsc::channel::<ProviderDelta>(128);
let forwarder = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
let mapped = match event {
ProviderDelta::TextDelta { delta } => AgentProgress::SubagentTextDelta {
agent_id: agent_id.clone(),
task_id: task_id.clone(),
delta,
iteration,
},
ProviderDelta::ThinkingDelta { delta } => {
AgentProgress::SubagentThinkingDelta {
agent_id: agent_id.clone(),
task_id: task_id.clone(),
delta,
iteration,
}
}
ProviderDelta::ToolCallStart { .. }
| ProviderDelta::ToolCallArgsDelta { .. } => continue,
};
// Await backpressure so streamed deltas arrive in order.
if sink.send(mapped).await.is_err() {
break;
}
}
});
(Some(tx), Some(forwarder))
}
}
/// No-op reporter for triage / tests.
pub(crate) struct NullProgress;
@@ -1,98 +0,0 @@
//! Turn-state observer seam.
//!
//! The engine drives the loop over a `Vec<ChatMessage>` working buffer, but the
//! three callers want to *do* different things around each step:
//!
//! * the channel loop wants nothing extra ([`NullObserver`]);
//! * the subagent wants per-iteration transcript persistence, usage
//! accumulation, and worker-thread mirroring (assistant intents, per-call
//! results, batched text-mode results, final response);
//! * `Agent::turn` wants its `ContextManager` reduction before each dispatch,
//! transcript persistence, and per-turn usage/cost snapshots.
//!
//! [`TurnObserver`] is the seam: every method defaults to a no-op, so an impl
//! only overrides the hooks its caller needs. The engine still owns the
//! universal concerns (stop hooks, context guard, token-budget trim, the
//! circuit breaker) inline — the observer is for caller-specific side effects.
use anyhow::Result;
use async_trait::async_trait;
use super::tool_source::ToolSource;
use crate::openhuman::agent::harness::parse::ParsedToolCall;
use crate::openhuman::inference::provider::{ChatMessage, ToolCall, UsageInfo};
#[async_trait]
pub(crate) trait TurnObserver: Send {
/// Called before each provider dispatch, after the engine's own context
/// guard + token-budget trim. `Agent::turn` runs its `ContextManager`
/// reduction chain here. Default: no-op.
async fn before_dispatch(
&mut self,
_history: &mut Vec<ChatMessage>,
_tools: &mut dyn ToolSource,
_iteration: usize,
) -> Result<()> {
Ok(())
}
/// Called once per provider response that carried a usage block, so the
/// caller can accumulate its own token tally / transcript usage snapshot.
fn record_usage(&mut self, _provider: &str, _model: &str, _usage: &UsageInfo) {}
/// Called after the assistant message for this iteration is committed to
/// the engine's working buffer. `response_text` is the raw provider text
/// (pre native serialization); `reasoning_content` is the thinking-model
/// content to round-trip; `native_tool_calls` are the provider's structured
/// calls (empty in text/prompt mode); `parsed_calls` are the engine-parsed
/// calls (empty when `is_final`). `Agent::turn` uses these to rebuild its
/// typed `ConversationMessage` history; the subagent mirrors to its worker
/// thread.
#[allow(clippy::too_many_arguments)]
async fn on_assistant(
&mut self,
_display_text: &str,
_response_text: &str,
_reasoning_content: Option<&str>,
_native_tool_calls: &[ToolCall],
_parsed_calls: &[ParsedToolCall],
_iteration: usize,
_is_final: bool,
) {
}
/// Called after one tool's result is known, in native-tool mode (one
/// `role:tool` message per call). Subagent mirrors per-call results to its
/// worker thread; `Agent::turn` buffers them to rebuild typed history.
fn on_tool_result(
&mut self,
_call_id: &str,
_tool_name: &str,
_result_text: &str,
_success: bool,
_iteration: usize,
) {
}
/// Called after a batched `[Tool results]` user message is committed
/// (text/prompt mode, where there are no per-call `role:tool` messages).
fn on_results_batch(&mut self, _content: &str, _iteration: usize) {}
/// Called after the iteration's history is finalized (the transcript
/// persistence point) — both after the final response and after each tool
/// round's results are appended.
fn after_iteration(&mut self, _history: &[ChatMessage], _iteration: usize) {}
/// Whether an empty final response (no text, no tool calls) is acceptable.
/// The channel/subagent loops return it as `Ok("")`; `Agent::turn` treats
/// it as a degenerate/poisoned completion and surfaces an error instead of
/// a silent blank reply (bug-report-2026-05-26 A1). Default: allowed.
fn allow_empty_final(&self) -> bool {
true
}
}
/// No-op observer for the channel/CLI/triage loop, which keeps no extra state.
pub(crate) struct NullObserver;
impl TurnObserver for NullObserver {}
@@ -1,150 +0,0 @@
//! Tool sourcing seam for the turn engine.
//!
//! The three former loops resolved "what tools can the model call this turn and
//! how do I execute one" differently:
//!
//! * the channel loop advertised `registry + extra` filtered by a visibility
//! whitelist, and executed via the shared [`run_one_tool`];
//! * the subagent loop advertised a definition-filtered slice of the parent's
//! tools (with lazy toolkit registration), and had its own per-call body;
//! * `Agent::turn` advertised `Agent.visible_tool_specs` and executed via the
//! richer `Agent::execute_tool_call` (session policy + per-call permission
//! levels + `execute_with_options`).
//!
//! [`ToolSource`] is the single seam the engine talks to: it advertises the
//! request specs and owns per-call execution (including the start/complete
//! progress events). [`RegistryToolSource`] is the channel/CLI/triage impl; the
//! subagent and `Agent` impls land in later phases.
use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use super::super::payload_summarizer::PayloadSummarizer;
use super::progress::ProgressReporter;
use super::{run_one_tool, ToolRunResult};
use crate::openhuman::agent::harness::parse::ParsedToolCall;
use crate::openhuman::agent_tool_policy::ToolPolicySession;
use crate::openhuman::tools::policy::ToolPolicy;
use crate::openhuman::tools::{Tool, ToolSpec};
/// What the engine needs from "the set of tools available this turn".
#[async_trait]
pub(crate) trait ToolSource: Send {
/// The deduped, visibility-filtered specs to advertise to the provider
/// this turn. Re-read each iteration so impls that register tools lazily
/// (subagent toolkit resolution) can grow the advertised set over a turn.
fn request_specs(&self) -> &[ToolSpec];
/// Execute one parsed tool call end-to-end, emitting its `ToolCallStarted`
/// / `ToolCallCompleted` (or flavor-equivalent) progress events. Returns a
/// [`ToolRunResult`] the engine folds into history + the circuit breaker.
async fn execute_call(
&mut self,
call: &ParsedToolCall,
iteration: usize,
progress: &dyn ProgressReporter,
progress_call_id: &str,
) -> ToolRunResult;
/// Replace the caller-specific runtime snapshot after a dynamic refresh.
/// Default no-op for non-agent callers.
#[allow(clippy::too_many_arguments)]
fn sync_agent_surface(
&mut self,
_tools: Arc<Vec<Box<dyn Tool>>>,
_visible_tool_names: HashSet<String>,
_tool_policy_session: ToolPolicySession,
_payload_summarizer: Option<Arc<dyn PayloadSummarizer>>,
_prefer_markdown: bool,
_budget_bytes: usize,
_should_send_specs: bool,
_advertised_specs: Vec<ToolSpec>,
) {
}
}
/// The channel/CLI/triage tool source: a persistent `registry`, optional
/// per-turn synthesised `extra` tools, an optional visibility whitelist, and a
/// pluggable [`ToolPolicy`]. Mirrors the original `run_tool_call_loop` tool
/// plumbing exactly.
pub(crate) struct RegistryToolSource<'a> {
registry: &'a [Box<dyn Tool>],
extra: &'a [Box<dyn Tool>],
visible: Option<&'a HashSet<String>>,
tool_policy: &'a dyn ToolPolicy,
payload_summarizer: Option<&'a dyn PayloadSummarizer>,
specs: Vec<ToolSpec>,
}
impl<'a> RegistryToolSource<'a> {
pub(crate) fn new(
registry: &'a [Box<dyn Tool>],
extra: &'a [Box<dyn Tool>],
visible: Option<&'a HashSet<String>>,
tool_policy: &'a dyn ToolPolicy,
payload_summarizer: Option<&'a dyn PayloadSummarizer>,
) -> Self {
// Filter to visible tools, then dedup by name before sending to the
// provider. Registry tools may collide with per-turn synthesised
// extra_tools (e.g. an `ArchetypeDelegationTool` whose
// `delegate_name = "research"` shadowing a same-named skill). Some
// providers 400 on duplicate tool names — see TAURI-RUST-4.
let filtered: Vec<ToolSpec> = registry
.iter()
.chain(extra.iter())
.filter(|tool| visible.map(|s| s.contains(tool.name())).unwrap_or(true))
.map(|tool| tool.spec())
.collect();
let specs = crate::openhuman::agent::harness::session::dedup_visible_tool_specs(filtered);
Self {
registry,
extra,
visible,
tool_policy,
payload_summarizer,
specs,
}
}
fn is_visible(&self, name: &str) -> bool {
self.visible.map(|s| s.contains(name)).unwrap_or(true)
}
}
#[async_trait]
impl ToolSource for RegistryToolSource<'_> {
fn request_specs(&self) -> &[ToolSpec] {
&self.specs
}
async fn execute_call(
&mut self,
call: &ParsedToolCall,
iteration: usize,
progress: &dyn ProgressReporter,
progress_call_id: &str,
) -> ToolRunResult {
// Look up the tool by name in the combined registry + extras, subject
// to the visibility whitelist. A hallucinated / filtered-out name
// resolves to `None`, which `run_one_tool` reports as an unknown tool.
let tool_opt: Option<&dyn Tool> = self
.registry
.iter()
.chain(self.extra.iter())
.find(|t| t.name() == call.name && self.is_visible(t.name()))
.map(|b| b.as_ref());
run_one_tool(
tool_opt,
call,
iteration,
progress,
self.tool_policy,
self.payload_summarizer,
progress_call_id,
crate::openhuman::tokenjuice::AgentTokenjuiceCompression::Full,
)
.await
}
}
-537
View File
@@ -1,537 +0,0 @@
//! Shared per-call tool executor.
//!
//! [`run_one_tool`] runs the full lifecycle of a single parsed tool call:
//!
//! 1. emit `ToolCallStarted` (for *every* call, including ones rejected below,
//! so a client row created from streamed args always gets a terminal event);
//! 2. evaluate the pluggable [`ToolPolicy`] (deny short-circuits everything,
//! including approval side-effects);
//! 3. guard `CliRpcOnly` scope (such tools can't run in the autonomous loop);
//! 4. route external-effect tools through the process-global `ApprovalGate`;
//! 5. execute with the configured timeout, then scrub credentials, apply
//! tokenjuice, the per-tool size cap, and the optional payload summarizer;
//! 6. stamp the approval audit "after" row (#2135);
//! 7. emit `ToolCallCompleted`.
//!
//! It returns a [`ToolRunResult`] (`text` + `success`). The caller owns history
//! shaping (native `role:tool` messages vs XML `<tool_result>` blocks) and the
//! repeated-failure circuit breaker, both of which it drives uniformly from the
//! returned `success`/`text` regardless of which branch produced them.
//!
//! This body was lifted verbatim (behavior-preserving) from the canonical
//! `run_tool_call_loop` in `tool_loop.rs`; the three loops now call it instead
//! of each carrying their own copy.
use super::super::payload_summarizer::PayloadSummarizer;
use super::progress::ProgressReporter;
use crate::openhuman::agent::harness::parse::ParsedToolCall;
use crate::openhuman::tools::policy::{PolicyDecision, ToolPolicy};
use crate::openhuman::tools::traits::ToolScope;
use crate::openhuman::tools::Tool;
use super::super::credentials::scrub_credentials;
/// Outcome of a single tool call. `text` is what should be fed back to the
/// model (a result body, an error, or a denial reason); `success` is `false`
/// for any non-OK outcome (policy/approval denial, scope rejection, timeout,
/// tool error, unknown tool) so the caller's circuit breaker and history
/// formatting can treat every failure mode uniformly.
pub(crate) struct ToolRunResult {
pub text: String,
pub success: bool,
}
/// Grace added to the outer harness deadline when a tool enforces its **own**
/// per-call timeout internally (a `Secs` policy). The tool's own timeout then
/// fires first — it produces a more specific message and actually kills the
/// child process, whereas the harness backstop merely drops the future.
const TOOL_TIMEOUT_GRACE_SECS: u64 = 5;
/// Map a [`ToolTimeout`] policy to `(deadline, effective_secs)` for
/// [`run_one_tool`]. `deadline` is `None` for an unbounded run (no harness
/// timeout); `effective_secs` is the value surfaced in the timeout message
/// (unused when `deadline` is `None`).
pub(crate) fn resolve_tool_deadline(
policy: crate::openhuman::tools::traits::ToolTimeout,
) -> (Option<std::time::Duration>, u64) {
use crate::openhuman::tool_timeout::{MAX_TIMEOUT_SECS, MIN_TIMEOUT_SECS};
use crate::openhuman::tools::traits::ToolTimeout;
match policy {
ToolTimeout::Inherit => {
let s = crate::openhuman::tool_timeout::tool_execution_timeout_secs();
(Some(std::time::Duration::from_secs(s)), s)
}
ToolTimeout::Secs(req) => {
let s = req.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS);
(
Some(std::time::Duration::from_secs(
s.saturating_add(TOOL_TIMEOUT_GRACE_SECS),
)),
s,
)
}
ToolTimeout::Unbounded => (None, 0),
}
}
/// Execute one parsed tool call end-to-end. See the module docs for the full
/// lifecycle. `tool_opt` is the (already visibility-filtered) tool the caller
/// resolved by name — `None` means the model requested an unknown/filtered-out
/// tool, which is reported as a structured error the LLM can correct next turn.
///
/// `progress_call_id` is the stable id threaded through the start/complete
/// event pair (and any preceding args-delta events) so consumers can reconcile
/// tool rows by id.
pub(crate) async fn run_one_tool(
tool_opt: Option<&dyn Tool>,
call: &ParsedToolCall,
iteration: usize,
progress: &dyn ProgressReporter,
tool_policy: &dyn ToolPolicy,
payload_summarizer: Option<&dyn PayloadSummarizer>,
progress_call_id: &str,
tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression,
) -> ToolRunResult {
let iteration_u32 = (iteration + 1) as u32;
// Compute the human label + contextual detail once, server-side, from the
// resolved tool (covers dynamic Composio/MCP/integration tools the client
// can't know). `None` for an unknown/filtered-out tool → the client
// formatter falls back to its own labels.
let (display_label, display_detail) = match tool_opt {
Some(tool) => (
tool.display_label(&call.arguments),
tool.display_detail(&call.arguments),
),
None => (None, None),
};
// Emit a "tool started" event for every parsed call, even ones that will be
// rejected below (approval denied, CliRpcOnly, unknown) — the client-side
// row was created from the streamed args and needs a terminal event.
progress
.tool_started(
progress_call_id,
&call.name,
&call.arguments,
iteration_u32,
display_label.as_deref(),
display_detail.as_deref(),
)
.await;
// Helper: emit a failed "tool completed" event for an early-exit path
// (denied / CliRpcOnly / unknown) so the client row flips to `error`
// instead of staying running.
let emit_failed_completion = |message: &str| {
let message = message.to_string();
async move {
progress
.tool_completed(
progress_call_id,
&call.name,
false,
&message,
0,
iteration_u32,
)
.await;
}
};
// ── Tool policy check (#2131) ─────────────────
// Evaluate the pluggable ToolPolicy before any approval or execution. If
// the policy denies the call, skip everything (including approval
// side-effects) and return the denial reason as a tool error to the model.
if let PolicyDecision::Deny(reason) = tool_policy.evaluate(&call.name, &call.arguments) {
tracing::debug!(
iteration,
tool = call.name.as_str(),
reason = %reason,
"[agent_loop] tool policy denied tool call"
);
let denied = format!("Tool '{}' denied by policy: {reason}", call.name);
emit_failed_completion(&denied).await;
return ToolRunResult {
text: denied,
success: false,
};
}
let Some(tool) = tool_opt else {
tracing::warn!(
iteration,
tool = call.name.as_str(),
"[agent_loop] unknown tool requested"
);
let msg = format!("Unknown tool: {}", call.name);
emit_failed_completion(&msg).await;
return ToolRunResult {
text: msg,
success: false,
};
};
tracing::debug!(
iteration,
tool = call.name.as_str(),
found = true,
"[agent_loop] executing tool"
);
// Scope check: CliRpcOnly tools cannot run in the autonomous agent loop.
if tool.scope() == ToolScope::CliRpcOnly {
tracing::warn!(
iteration,
tool = call.name.as_str(),
"[agent_loop] tool scope is CliRpcOnly — denied in agent loop"
);
let denied = format!(
"Tool '{}' is only available via explicit CLI/RPC invocation, not in the autonomous agent loop.",
call.name
);
emit_failed_completion(&denied).await;
return ToolRunResult {
text: denied,
success: false,
};
}
// ── External-effect approval gate (#1339, #2135) ──
// Tools whose `external_effect()` returns true route through the
// process-global `ApprovalGate` so the UI can prompt the user before
// `execute()` runs. The gate is `None` when supervised mode is disabled or
// in test envs — behavior matches the pre-#1339 path.
//
// `approval_request_id` carries the persisted row id forward so we can
// stamp the terminal execution outcome onto the same `pending_approvals`
// row after the tool finishes (issue #2135). `None` means the tool was
// either not gated, was session-allowlist-shortcutted, or was denied —
// none of which produce an audit row that needs an "after" entry.
let mut approval_request_id: Option<String> = None;
let mut approval_gate_for_audit: Option<
std::sync::Arc<crate::openhuman::approval::ApprovalGate>,
> = None;
if tool.external_effect_with_args(&call.arguments) {
if let Some(gate) = crate::openhuman::approval::ApprovalGate::try_global() {
let summary = crate::openhuman::approval::summarize_action(&call.name, &call.arguments);
let redacted = crate::openhuman::approval::redact_args(&call.arguments);
let (outcome, request_id) =
gate.intercept_audited(&call.name, &summary, redacted).await;
match outcome {
crate::openhuman::approval::GateOutcome::Allow => {
approval_request_id = request_id;
if approval_request_id.is_some() {
approval_gate_for_audit = Some(gate);
}
}
crate::openhuman::approval::GateOutcome::Deny { reason } => {
tracing::warn!(
iteration,
tool = call.name.as_str(),
reason = %reason,
"[agent_loop] approval gate denied tool call"
);
emit_failed_completion(&reason).await;
return ToolRunResult {
text: reason,
success: false,
};
}
}
}
}
// A tool that exposes a caller-supplied per-call budget (e.g. `shell`'s
// `timeout_secs`) raises/lowers the outer harness deadline to match; the
// resolver bounds it and falls back to the global config when absent or
// out of range. Without this, the global cap would kill a long-running
// command before the tool's own per-call timeout could take effect.
// Resolve this call's wall-clock policy. Most tools `Inherit` the global,
// config-driven timeout; scripting tools (`shell`/`node_exec`/`npm_exec`)
// run `Unbounded` unless the caller passed an explicit `timeout_secs`
// (`Secs`). See issue #4023 — a long-but-legitimate script must not be
// hard-killed by a default cap.
let policy = tool.timeout_policy(&call.arguments);
let (tool_deadline, timeout_secs) = resolve_tool_deadline(policy);
match policy {
crate::openhuman::tools::traits::ToolTimeout::Secs(req) => tracing::debug!(
iteration,
tool = call.name.as_str(),
requested_timeout_secs = req,
effective_timeout_secs = timeout_secs,
"[agent_loop] tool requested an explicit per-call timeout"
),
crate::openhuman::tools::traits::ToolTimeout::Unbounded => tracing::debug!(
iteration,
tool = call.name.as_str(),
"[agent_loop] tool runs unbounded (no harness timeout) — no explicit timeout_secs"
),
crate::openhuman::tools::traits::ToolTimeout::Inherit => {}
}
let tool_started = std::time::Instant::now();
let outcome = match tool_deadline {
Some(deadline) => {
tokio::time::timeout(deadline, tool.execute(call.arguments.clone())).await
}
// Unbounded: run to completion with no harness-imposed deadline.
None => Ok(tool.execute(call.arguments.clone()).await),
};
let elapsed_ms = tool_started.elapsed().as_millis() as u64;
let (result_text, success) = match outcome {
Ok(Ok(r)) => {
let output = r.output();
let success = !r.is_error;
if success {
tracing::debug!(
iteration,
tool = call.name.as_str(),
output_len = output.len(),
"[agent_loop] tool succeeded"
);
let mut scrubbed = scrub_credentials(&output);
let (compacted, tj_stats) =
crate::openhuman::tokenjuice::compact_tool_output_with_policy(
&call.name,
Some(&call.arguments),
&scrubbed,
Some(0),
tokenjuice_compression,
)
.await;
if tj_stats.applied {
log::debug!(
"[agent_loop] tokenjuice applied tool={} rule={} {}->{} bytes",
call.name,
tj_stats.rule_id,
tj_stats.original_bytes,
tj_stats.compacted_bytes
);
scrubbed = compacted;
}
// Per-tool max_result_size_chars cap. When a tool sets it and
// the (post-tokenjuice) body still exceeds the cap, truncate
// here and skip the global payload summarizer for this call —
// the cap is fast and deterministic, the summarizer is the
// fallback for tools that don't know their own size budget.
let mut hit_per_tool_cap = false;
if let Some(cap) = tool.max_result_size_chars() {
let char_count = scrubbed.chars().count();
if char_count > cap {
let truncated: String = scrubbed.chars().take(cap).collect();
let dropped = char_count - cap;
log::info!(
"[agent_loop] per-tool cap applied tool={} cap_chars={} original_chars={} dropped_chars={}",
call.name,
cap,
char_count,
dropped,
);
scrubbed = format!(
"{truncated}\n\n[truncated by tool cap: {dropped} more chars not shown]"
);
hit_per_tool_cap = true;
}
}
if !hit_per_tool_cap {
if let Some(summarizer) = payload_summarizer {
log::debug!(
"[agent_loop] payload_summarizer intercepting tool={} bytes={}",
call.name,
scrubbed.len()
);
match summarizer
.maybe_summarize(&call.name, None, &scrubbed)
.await
{
Ok(Some(payload)) => {
log::info!(
"[agent_loop] payload_summarizer compressed tool={} {}->{} bytes",
call.name,
payload.original_bytes,
payload.summary_bytes
);
scrubbed = payload.summary;
}
Ok(None) => {
log::debug!(
"[agent_loop] payload_summarizer pass-through tool={} bytes={}",
call.name,
scrubbed.len()
);
}
Err(e) => {
log::warn!(
"[agent_loop] payload_summarizer error tool={} err={} (passing raw payload through)",
call.name,
e
);
}
}
}
}
(scrubbed, true)
} else {
// Scrub before logging — a failing tool payload can carry
// credentials / PII, so never log the raw output.
let scrubbed = scrub_credentials(&output);
tracing::warn!(
iteration,
tool = call.name.as_str(),
"[agent_loop] tool returned error: {scrubbed}"
);
let (compacted, _) = crate::openhuman::tokenjuice::compact_tool_output_with_policy(
&call.name,
Some(&call.arguments),
&scrubbed,
Some(1),
tokenjuice_compression,
)
.await;
(format!("Error: {compacted}"), false)
}
}
Ok(Err(e)) => {
// Route through `report_error_or_expected` (not the unconditional
// `report_error`) so an error a downstream layer already classified
// as expected user-state isn't re-reported as a hard Sentry event
// here. The integrations client (`integrations/client.rs`) already
// demotes its backend 4xx/auth-state failures via
// `report_error_or_expected`, but it ALSO bubbles the error up; it
// lands in this `Ok(Err(_))` arm and was being re-reported as a
// hard `tool`/`execute` event — the double-report behind Sentry
// TAURI-RUST-84E (`Backend returned 401 Unauthorized for POST
// .../agent-integrations/parallel/search: Invalid token`, a
// user-end invalid/expired session token with no openhuman-side
// lever). Genuine tool failures don't match any classifier arm and
// still surface as hard errors — only already-classified
// user-state errors are demoted to a warn/info breadcrumb.
crate::core::observability::report_error_or_expected(
format!("{e:#}").as_str(),
"tool",
"execute",
&[
("tool", call.name.as_str()),
("outcome", "failed"),
("iteration", &(iteration + 1).to_string()),
],
);
(format!("Error executing {}: {e}", call.name), false)
}
Err(_) => {
let msg = format!(
"tool '{}' timed out after {} seconds",
call.name, timeout_secs
);
crate::core::observability::report_error(
msg.as_str(),
"tool",
"execute",
&[
("tool", call.name.as_str()),
("outcome", "timeout"),
("timeout_secs", &timeout_secs.to_string()),
("iteration", &(iteration + 1).to_string()),
],
);
(
format!(
"Error: tool '{}' timed out after {} seconds",
call.name, timeout_secs
),
false,
)
}
};
progress
.tool_completed(
progress_call_id,
&call.name,
success,
&result_text,
elapsed_ms,
iteration_u32,
)
.await;
// ── Approval audit after-action row (#2135) ────
// Stamp the terminal status onto the same `pending_approvals` row the gate
// created before execution, so the audit trail carries both the before
// (approval) and after (executed_at + outcome). Best-effort: a write
// failure here is logged but not propagated to the agent.
if let (Some(gate), Some(req_id)) = (
approval_gate_for_audit.as_ref(),
approval_request_id.as_ref(),
) {
let exec_outcome = if success {
crate::openhuman::approval::ExecutionOutcome::Success
} else {
crate::openhuman::approval::ExecutionOutcome::Failure
};
let err_text = if success {
None
} else {
Some(result_text.as_str())
};
gate.record_execution(req_id, exec_outcome, err_text);
}
ToolRunResult {
text: result_text,
success,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::tools::traits::ToolTimeout;
#[test]
fn unbounded_policy_imposes_no_deadline() {
let (deadline, _secs) = resolve_tool_deadline(ToolTimeout::Unbounded);
assert!(
deadline.is_none(),
"scripting tools with no explicit timeout must run unbounded"
);
}
#[test]
fn inherited_policy_uses_shared_deadline() {
let (deadline, secs) = resolve_tool_deadline(ToolTimeout::Inherit);
assert!(secs >= crate::openhuman::tool_timeout::MIN_TIMEOUT_SECS);
assert_eq!(deadline, Some(std::time::Duration::from_secs(secs)));
}
#[test]
fn explicit_secs_policy_adds_grace_and_reports_value() {
let (deadline, secs) = resolve_tool_deadline(ToolTimeout::Secs(300));
// Reported value is the requested budget; the outer deadline gets a
// grace margin so the tool's own timeout fires first.
assert_eq!(secs, 300);
assert_eq!(
deadline,
Some(std::time::Duration::from_secs(
300 + TOOL_TIMEOUT_GRACE_SECS
))
);
}
#[test]
fn explicit_secs_policy_clamps_out_of_range() {
// Above MAX clamps down; the deadline is built from the clamped value.
let (deadline, secs) = resolve_tool_deadline(ToolTimeout::Secs(999_999));
assert_eq!(secs, crate::openhuman::tool_timeout::MAX_TIMEOUT_SECS);
assert_eq!(
deadline,
Some(std::time::Duration::from_secs(
crate::openhuman::tool_timeout::MAX_TIMEOUT_SECS + TOOL_TIMEOUT_GRACE_SECS
))
);
// Below MIN clamps up to MIN.
let (_d, secs_min) = resolve_tool_deadline(ToolTimeout::Secs(0));
assert_eq!(secs_min, crate::openhuman::tool_timeout::MIN_TIMEOUT_SECS);
}
}
+26 -5
View File
@@ -155,7 +155,13 @@ tokio::task_local! {
/// 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>>;
///
/// Behind an `Arc<Mutex<…>>` (not a plain `Arc<Vec<…>>`) so a source can be
/// **appended live** mid-turn — the graph's `SuperContextMiddleware` runs its
/// scout during the harness run (after the initial list is scoped) and
/// registers its source via [`push_agent_context_prepared_source`] so a later
/// `agent_prepare_context` call in the same turn still self-suppresses.
pub static AGENT_CONTEXT_PREPARED_SOURCES: Arc<std::sync::Mutex<Vec<AgentContextPreparedSource>>>;
}
/// Returns a clone of the current parent execution context, if one is set.
@@ -175,15 +181,30 @@ where
}
/// Returns the one-shot context-preparation sources that have already run for
/// the current parent turn.
/// the current parent turn (a snapshot of the live list).
pub fn current_agent_context_prepared_sources() -> Vec<AgentContextPreparedSource> {
AGENT_CONTEXT_PREPARED_SOURCES
.try_with(|sources| sources.as_ref().clone())
.try_with(|sources| sources.lock().map(|s| s.clone()).unwrap_or_default())
.unwrap_or_default()
}
/// Append a source to the current turn's prepared-context list, live.
///
/// Used by the graph's `SuperContextMiddleware`, which prepares context *during*
/// the harness run (after [`with_agent_context_prepared_sources`] scoped the
/// initial list) so a later `agent_prepare_context` tool call in the same turn
/// observes it and self-suppresses. No-op outside an agent turn.
pub fn push_agent_context_prepared_source(source: AgentContextPreparedSource) {
let _ = AGENT_CONTEXT_PREPARED_SOURCES.try_with(|sources| {
if let Ok(mut guard) = sources.lock() {
guard.push(source);
}
});
}
/// Run `future` with the current turn's already-prepared context sources
/// installed.
/// installed. The list is appendable mid-turn via
/// [`push_agent_context_prepared_source`].
pub async fn with_agent_context_prepared_sources<F, R>(
sources: Vec<AgentContextPreparedSource>,
future: F,
@@ -192,6 +213,6 @@ where
F: std::future::Future<Output = R>,
{
AGENT_CONTEXT_PREPARED_SOURCES
.scope(Arc::new(sources), future)
.scope(Arc::new(std::sync::Mutex::new(sources)), future)
.await
}
+246
View File
@@ -0,0 +1,246 @@
//! The **channel/CLI turn graph** (issue #4249).
//!
//! Per the per-folder `graph.rs` convention, this is the harness's top-level
//! (channel/CLI) graph definition, its available tools, and its summarization
//! step — all thin over the shared tinyagents seam
//! ([`run_turn_via_tinyagents_shared`]).
//!
//! **Graph.** A single agent-loop turn driven by the tinyagents harness (the
//! canonical channel/CLI path; the legacy `run_tool_call_loop` is removed),
//! covering the loop's control-flow seams (iteration cap, circuit breakers, stop
//! hooks). When the caller supplies an `on_progress` sender the harness event
//! stream is mirrored onto `AgentProgress` (live tool timeline, streaming text
//! deltas, cost/token footer) via the same
//! [`OpenhumanEventBridge`](crate::openhuman::tinyagents::OpenhumanEventBridge)
//! the chat route uses.
//!
//! **Available tools.** Reuses the bus handler's `Arc`-shared tool sets
//! (`tools_registry: Arc<Vec<Box<dyn Tool>>>` + per-turn `extra_tools`),
//! advertised via [`SharedToolAdapter`](crate::openhuman::tinyagents::SharedToolAdapter)
//! and filtered by `visible_tool_names`. No early-exit tools on this path.
//!
//! **Summarization.** [`run_channel_turn_via_graph`] resolves the model's
//! effective context window before dispatch so the shared seam runs the
//! context-window summarization step (`tinyagents::summarize`) ahead of the
//! deterministic front-trim.
use std::collections::HashSet;
use std::sync::Arc;
use anyhow::Result;
use tokio::sync::mpsc::Sender;
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::config::{MultimodalConfig, MultimodalFileConfig};
use crate::openhuman::inference::provider::{ChatMessage, Provider};
use crate::openhuman::tinyagents::run_turn_via_tinyagents_shared;
use crate::openhuman::tools::Tool;
/// Drive a channel/CLI turn on the graph engine. Returns the final assistant
/// text. When `on_progress` is `Some`, the run streams and mirrors progress
/// onto `AgentProgress`; pass `None` for a fire-and-forget final-text turn.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_channel_turn_via_graph(
provider: Arc<dyn Provider>,
history: &mut Vec<ChatMessage>,
tools_registry: Arc<Vec<Box<dyn Tool>>>,
extra_tools: Vec<Box<dyn Tool>>,
visible_tool_names: Option<&HashSet<String>>,
model: &str,
temperature: f64,
max_iterations: usize,
multimodal: MultimodalConfig,
multimodal_files: MultimodalFileConfig,
on_progress: Option<Sender<AgentProgress>>,
) -> Result<String> {
let extra_arc = Arc::new(extra_tools);
// The callable set is the visibility whitelist (empty = every tool visible
// across the registry + per-turn extras). The runner advertises each via its
// own `spec()`, deduped by name (extras shadow the registry).
let allowed = visible_tool_names.cloned().unwrap_or_default();
// Capture native-tool support before `provider` is moved into the runner: the
// durable history append below serializes this turn's typed suffix with the
// matching dispatcher (native envelope vs prompt-guided text).
let native_tools = provider.supports_native_tools();
// Multimodal prep (parity with the chat route's
// `run_turn_via_tinyagents_session`, issue #4249): rehydrate image
// placeholders for vision-capable providers, then expand `[IMAGE:…]` /
// `[FILE:…]` markers into provider-ready content before dispatch. The
// expanded copy is provider-only — it is sent to the model but never
// persisted back into the channel `history` (see the reconstruction below).
let mut prepared = history.clone();
if provider.supports_vision()
&& crate::openhuman::agent::multimodal::has_image_placeholders(&prepared)
{
prepared = crate::openhuman::agent::multimodal::rehydrate_image_placeholders(&prepared);
}
let prepared = crate::openhuman::agent::multimodal::prepare_messages_for_provider(
&prepared,
&multimodal,
&multimodal_files,
)
.await
.map(|prepared| prepared.messages)
.unwrap_or(prepared);
// Resolve the provider's effective context window so the harness can run the
// context-window summarization step (issue #4249) on channel/CLI turns too —
// long-running channel threads otherwise grew unbounded until the cap error.
let context_window = provider.effective_context_window(model).await;
tracing::info!(
model,
max_iterations,
observed = on_progress.is_some(),
context_window,
"[channel:graph] routing channel turn through tinyagents harness"
);
let outcome = run_turn_via_tinyagents_shared(
provider,
model,
temperature,
prepared,
vec![extra_arc, tools_registry],
allowed,
max_iterations,
// Mirror the harness event stream onto AgentProgress when the caller
// (e.g. channel dispatch) supplied a progress sink.
on_progress,
// Top-level (parent) turn — no child-progress attribution.
None,
// Resolved above — drives the context-window summarization step.
context_window,
// No mid-flight steering on the channel path.
None,
// No early-exit pause on the channel path.
&[],
// Channels surface the cap as an error (legacy `ErrorCheckpoint`), so no
// graceful cap pause/summary here.
false,
// Bound the model's per-call output (legacy parity — channel turns ran at
// the standard per-turn budget).
Some(crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS),
// Context middlewares: cache-align + default tool-result byte cap (the
// channel path has no session `ContextManager` to source config from).
crate::openhuman::tinyagents::TurnContextMiddleware::defaults(),
)
.await?;
// Append only this turn's typed suffix (assistant tool-calls + tool results +
// final assistant), serialized with the matching dispatcher so a native tool
// round persists as the `{content, tool_calls}` / `{tool_call_id, content}`
// envelope (re-parsed by `convert::chat_message_to_message` next turn) rather
// than an assistant with no `tool_calls` followed by an orphan `tool` row.
// Using `outcome.conversation` (the typed messages-since-last-user) avoids
// indexing into a post-trim `outcome.history` with the pre-trim `prior_len`,
// which could drop current-turn messages when compaction reshaped the run.
use crate::openhuman::agent::dispatcher::ToolDispatcher;
let suffix = if native_tools {
crate::openhuman::agent::dispatcher::NativeToolDispatcher
.to_provider_messages(&outcome.conversation)
} else {
// History serialization is format-independent for prompt-guided providers
// (tool calls already ride the visible assistant text); the XML dispatcher
// renders the flat `[Tool results]` shape.
crate::openhuman::agent::dispatcher::XmlToolDispatcher
.to_provider_messages(&outcome.conversation)
};
history.extend(suffix);
Ok(outcome.text)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::inference::provider::{ChatResponse, ToolCall};
use crate::openhuman::tools::ToolResult;
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
struct PingTool;
#[async_trait]
impl Tool for PingTool {
fn name(&self) -> &str {
"ping"
}
fn description(&self) -> &str {
"ping"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object"})
}
async fn execute(&self, _a: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult::success("pong"))
}
}
struct PingThenDone {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for PingThenDone {
async fn chat_with_system(
&self,
_s: Option<&str>,
_m: &str,
_model: &str,
_t: f64,
) -> anyhow::Result<String> {
Ok(String::new())
}
async fn chat(
&self,
_r: crate::openhuman::inference::provider::ChatRequest<'_>,
_model: &str,
_t: f64,
) -> anyhow::Result<ChatResponse> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Ok(ChatResponse {
tool_calls: vec![ToolCall {
id: "p".to_string(),
name: "ping".to_string(),
arguments: "{}".to_string(),
extra_content: None,
}],
..Default::default()
})
} else {
Ok(ChatResponse {
text: Some("channel done".to_string()),
..Default::default()
})
}
}
fn supports_native_tools(&self) -> bool {
true
}
}
#[tokio::test]
async fn channel_turn_runs_through_the_graph() {
let registry: Arc<Vec<Box<dyn Tool>>> = Arc::new(vec![Box::new(PingTool)]);
let mut history = vec![ChatMessage::user("ping please")];
let text = run_channel_turn_via_graph(
Arc::new(PingThenDone {
calls: AtomicUsize::new(0),
}),
&mut history,
registry,
vec![],
None,
"mock-model",
0.0,
10,
MultimodalConfig::default(),
MultimodalFileConfig::default(),
None,
)
.await
.expect("channel graph turn runs");
assert_eq!(text, "channel done");
assert!(history.iter().any(|m| m.content.contains("pong")));
}
}
@@ -22,7 +22,6 @@
//! only the tag body (JSON) is used.
use crate::openhuman::agent::error::AgentError;
use crate::openhuman::agent::harness::tool_loop::run_tool_call_loop;
use crate::openhuman::context::guard::{ContextCheckResult, ContextGuard};
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
use crate::openhuman::inference::provider::Provider;
@@ -117,275 +116,6 @@ fn multimodal_file_cfg() -> crate::openhuman::config::MultimodalFileConfig {
// result injected → LLM produces final text.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn full_turn_cycle_user_llm_tool_result_final() {
// Round 1: LLM requests the "echo" tool.
// Round 2: LLM produces a final reply after seeing the tool result.
let provider = ScriptedProvider {
responses: Mutex::new(vec![
Ok(ChatResponse {
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
}),
Ok(ChatResponse {
text: Some("The tool said: echo-out".into()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
}),
]),
};
let mut history = vec![ChatMessage::user("please echo something")];
let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
let result = run_tool_call_loop(
&provider,
&mut history,
&tools,
"test-provider",
"model",
0.0,
true,
"channel",
&multimodal_cfg(),
&multimodal_file_cfg(),
2,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.expect("full turn cycle should succeed");
assert_eq!(result, "The tool said: echo-out");
// History should contain: user | assistant (tool call) | user (tool results) | assistant (final)
let roles: Vec<&str> = history.iter().map(|m| m.role.as_str()).collect();
assert_eq!(
roles,
vec!["user", "assistant", "user", "assistant"],
"history should have exactly 4 messages after one tool round-trip"
);
// The tool results message must contain the echo output.
let tool_results = &history[2];
assert_eq!(tool_results.role, "user");
assert!(
tool_results.content.contains("echo-out"),
"tool result must be echoed into history, got: {}",
tool_results.content
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Item 1 — MaxIterationsExceeded downcasts to typed AgentError.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn max_iterations_exceeded_downcasts_to_typed_agent_error() {
// Provider keeps requesting the same tool forever — the loop
// exhausts max_iterations=1 after one tool round-trip.
let provider = ScriptedProvider {
responses: Mutex::new(vec![Ok(ChatResponse {
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
})]),
};
let mut history = vec![ChatMessage::user("loop me")];
let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
let err = run_tool_call_loop(
&provider,
&mut history,
&tools,
"test-provider",
"model",
0.0,
true,
"channel",
&multimodal_cfg(),
&multimodal_file_cfg(),
1,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.expect_err("loop must fail when iterations exhausted");
// The anyhow error must downcast to the typed variant so callers
// (channels dispatch, web_channel run_chat_task, Sentry filter)
// can distinguish this deterministic outcome from transient failures.
let agent_err = err
.downcast_ref::<AgentError>()
.expect("error should downcast to AgentError");
assert!(
matches!(agent_err, AgentError::MaxIterationsExceeded { max: 1 }),
"expected MaxIterationsExceeded(1), got: {agent_err}"
);
// The string representation must contain the canonical prefix used
// by the Sentry-emit suppression checks in channels dispatch.
assert!(
crate::openhuman::agent::error::is_max_iterations_error(&err.to_string()),
"is_max_iterations_error must match the error text: {}",
err
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Item 4 — visible_tool_names whitelist: tool outside the set → treated
// as unknown; tool inside the set → executes normally.
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn visible_tool_names_rejects_tool_outside_whitelist() {
// Registry contains both "echo" and "ping".
// The whitelist only allows "ping".
// LLM calls "echo" (outside the whitelist) → should be treated as unknown.
// LLM then produces a final text after seeing the unknown-tool error.
let provider = ScriptedProvider {
responses: Mutex::new(vec![
Ok(ChatResponse {
text: Some(
// Model calls the filtered-out tool.
"<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into(),
),
tool_calls: vec![],
usage: None,
reasoning_content: None,
}),
Ok(ChatResponse {
text: Some("corrected response".into()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
}),
]),
};
let mut history = vec![ChatMessage::user("echo something")];
let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool), Box::new(PingTool)];
// Whitelist: only "ping" is visible.
let whitelist: HashSet<String> = ["ping".to_string()].into();
let result = run_tool_call_loop(
&provider,
&mut history,
&tools,
"test-provider",
"model",
0.0,
true,
"channel",
&multimodal_cfg(),
&multimodal_file_cfg(),
2,
None,
Some(&whitelist),
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.expect("loop should recover after whitelisted-out tool call");
assert_eq!(result, "corrected response");
// The tool results injected back to the LLM must report "echo" as unknown —
// it was filtered out by the whitelist.
let tool_results = history
.iter()
.find(|m| m.role == "user" && m.content.contains("[Tool results]"))
.expect("tool results must be appended after tool call");
assert!(
tool_results.content.contains("Unknown tool: echo"),
"whitelisted-out tool must be reported as unknown, got: {}",
tool_results.content
);
}
#[tokio::test]
async fn visible_tool_names_allows_tool_inside_whitelist() {
// Whitelist includes "echo" — the call should execute normally.
let provider = ScriptedProvider {
responses: Mutex::new(vec![
Ok(ChatResponse {
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
}),
Ok(ChatResponse {
text: Some("heard echo-out".into()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
}),
]),
};
let mut history = vec![ChatMessage::user("echo something")];
let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
let whitelist: HashSet<String> = ["echo".to_string()].into();
let result = run_tool_call_loop(
&provider,
&mut history,
&tools,
"test-provider",
"model",
0.0,
true,
"channel",
&multimodal_cfg(),
&multimodal_file_cfg(),
2,
None,
Some(&whitelist),
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.expect("whitelisted tool should execute");
assert_eq!(result, "heard echo-out");
// Tool result must contain the actual tool output, not the unknown-tool message.
let tool_results = history
.iter()
.find(|m| m.role == "user" && m.content.contains("[Tool results]"))
.expect("tool results must be appended");
assert!(
tool_results.content.contains("echo-out"),
"tool should have executed and returned its output, got: {}",
tool_results.content
);
assert!(
!tool_results.content.contains("Unknown tool"),
"allowed tool must not be reported as unknown, got: {}",
tool_results.content
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Item 5 — ContextGuard: ContextExhausted is surfaced cleanly.
// (Unit test on the guard directly; the loop integration path is
// exercised implicitly via context_guard.check() inside the loop.)
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn context_guard_exhausted_after_circuit_breaker_and_95pct_utilization() {
// Simulate the scenario where compaction has failed 3 times (circuit
-166
View File
@@ -1,166 +0,0 @@
//! Graceful interrupt fence — handles SIGINT / Ctrl+C and `/stop` commands.
//!
//! The interrupt fence is checked at key points in the orchestrator loop:
//! - Before each DAG level execution
//! - Before each tool execution in the tool loop
//! - Inside sub-agent spawn points
//!
//! On interrupt, running sub-agents are cancelled, memory is flushed,
//! and the Archivist fires with partial context.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
/// Thread-safe interrupt flag that can be checked throughout the agent harness.
#[derive(Clone)]
pub struct InterruptFence {
flag: Arc<AtomicBool>,
}
impl InterruptFence {
/// Create a new interrupt fence (not triggered).
pub fn new() -> Self {
Self {
flag: Arc::new(AtomicBool::new(false)),
}
}
/// Check whether an interrupt has been requested.
pub fn is_interrupted(&self) -> bool {
self.flag.load(Ordering::Relaxed)
}
/// Trigger the interrupt (called from signal handler or `/stop` command).
pub fn trigger(&self) {
self.flag.store(true, Ordering::Relaxed);
tracing::info!("[interrupt] interrupt fence triggered");
}
/// Reset the fence (e.g. at the start of a new session).
pub fn reset(&self) {
self.flag.store(false, Ordering::Relaxed);
}
/// Get a raw `Arc<AtomicBool>` handle for passing to signal handlers.
pub fn flag_handle(&self) -> Arc<AtomicBool> {
self.flag.clone()
}
/// Install a `tokio::signal::ctrl_c()` handler that triggers this fence.
///
/// This spawns a background task that waits for Ctrl+C and sets the flag.
/// The task runs until the process exits.
pub fn install_signal_handler(&self) {
let flag = self.flag.clone();
tokio::spawn(async move {
loop {
match tokio::signal::ctrl_c().await {
Ok(()) => {
if flag.load(Ordering::Relaxed) {
// Second Ctrl+C — hard exit.
tracing::warn!("[interrupt] second Ctrl+C received, forcing exit");
std::process::exit(130);
}
flag.store(true, Ordering::Relaxed);
tracing::info!(
"[interrupt] Ctrl+C received — gracefully stopping. Press again to force exit."
);
}
Err(e) => {
tracing::error!("[interrupt] failed to listen for Ctrl+C: {e}");
break;
}
}
}
});
}
}
impl Default for InterruptFence {
fn default() -> Self {
Self::new()
}
}
/// Error returned when an operation is cancelled due to an interrupt.
#[derive(Debug, thiserror::Error)]
#[error("operation interrupted by user")]
pub struct InterruptedError;
/// Helper: check the fence and return `Err(InterruptedError)` if triggered.
pub fn check_interrupt(fence: &InterruptFence) -> Result<(), InterruptedError> {
if fence.is_interrupted() {
Err(InterruptedError)
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_fence_is_not_interrupted() {
let fence = InterruptFence::new();
assert!(!fence.is_interrupted());
}
#[test]
fn trigger_sets_interrupted() {
let fence = InterruptFence::new();
fence.trigger();
assert!(fence.is_interrupted());
}
#[test]
fn reset_clears_interrupted() {
let fence = InterruptFence::new();
fence.trigger();
assert!(fence.is_interrupted());
fence.reset();
assert!(!fence.is_interrupted());
}
#[test]
fn flag_handle_shares_state() {
let fence = InterruptFence::new();
let handle = fence.flag_handle();
handle.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(fence.is_interrupted());
}
#[test]
fn clone_shares_state() {
let fence = InterruptFence::new();
let clone = fence.clone();
fence.trigger();
assert!(clone.is_interrupted());
}
#[test]
fn default_is_not_interrupted() {
let fence = InterruptFence::default();
assert!(!fence.is_interrupted());
}
#[test]
fn check_interrupt_ok_when_not_triggered() {
let fence = InterruptFence::new();
assert!(check_interrupt(&fence).is_ok());
}
#[test]
fn check_interrupt_err_when_triggered() {
let fence = InterruptFence::new();
fence.trigger();
let err = check_interrupt(&fence).unwrap_err();
assert_eq!(err.to_string(), "operation interrupted by user");
}
#[test]
fn interrupted_error_display() {
let err = InterruptedError;
assert_eq!(format!("{err}"), "operation interrupted by user");
}
}
+8 -16
View File
@@ -19,6 +19,7 @@
//! - **[`fork_context`]**: Task-local storage for parent context sharing.
//! - **[`interrupt`]**: Infrastructure for graceful cancellation of agent loops.
pub mod agent_graph;
pub mod archivist;
pub(crate) mod builtin_definitions;
pub(crate) mod compaction;
@@ -27,56 +28,47 @@ pub mod definition;
pub(crate) mod definition_loader;
pub(crate) mod engine;
pub mod fork_context;
pub(crate) mod graph;
mod instructions;
pub mod interrupt;
pub(crate) mod memory_context;
pub(crate) mod memory_context_safety;
pub mod model_vision_context;
mod parse;
pub(crate) mod parse;
pub(crate) mod payload_summarizer;
pub mod run_queue;
pub mod sandbox_context;
pub(crate) mod self_healing;
pub mod session;
pub(crate) mod session_queue;
pub(crate) mod spawn_depth_context;
pub mod subagent_runner;
pub mod task_recency_context;
pub(crate) mod token_budget;
pub(crate) mod tool_filter;
mod tool_loop;
pub(crate) mod tool_result_artifacts;
pub mod turn_attachments_context;
pub mod turn_subagent_usage;
pub mod worktree_context;
pub use agent_graph::{AgentGraph, AgentTurnRequest, AgentTurnResult, AgentTurnUsage};
pub use definition::{
AgentDefinition, AgentDefinitionRegistry, DefinitionSource, ModelSpec, PromptSource,
SandboxMode, ToolScope, TriggerMemoryAgent,
};
pub use fork_context::{
current_agent_context_prepared_sources, current_parent, with_agent_context_prepared_sources,
with_parent_context, AgentContextPreparedSource, ParentExecutionContext,
current_agent_context_prepared_sources, current_parent, push_agent_context_prepared_source,
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};
pub(crate) use spawn_depth_context::{current_spawn_depth, with_spawn_depth, MAX_SPAWN_DEPTH};
pub use subagent_runner::{run_subagent, SubagentRunError, SubagentRunOptions};
pub use task_recency_context::{current_task_recency_window, with_task_recency_window};
pub use worktree_context::{current_action_dir_override, with_action_dir_override};
pub(crate) use graph::run_channel_turn_via_graph;
pub(crate) use instructions::build_tool_instructions_filtered;
pub(crate) use parse::parse_tool_calls;
pub(crate) use tool_loop::run_tool_call_loop;
#[cfg(test)]
mod bughunt_tests;
#[cfg(test)]
pub(crate) mod test_support;
#[cfg(test)]
mod test_support_tests;
#[cfg(test)]
mod harness_gap_tests;
#[cfg(test)]
@@ -1,75 +0,0 @@
//! Task-local carrier for the **current session/sub-agent's user-configured
//! vision capability** so the deep turn engine's image gate can honor a custom
//! (BYOK) model's `model_registry.vision` flag without widening
//! [`crate::openhuman::agent::harness::engine::run_turn_engine`]'s signature.
//!
//! Managed-backend models advertise vision via `Provider::supports_vision()`, so
//! the gate accepts their image turns already. Custom OpenAI-compatible providers
//! report `supports_vision() == false` (a provider endpoint can't know a per-model
//! property), so without this the gate would reject image turns for a model the
//! user explicitly marked vision-capable. This task-local surfaces that per-model
//! flag — computed once at session build (where the full `Config` / `model_registry`
//! and the resolved model id coexist) — to the gate.
//!
//! Mirrors [`super::sandbox_context`]. When unset (CLI / direct invocation / tests
//! that never wrapped the call) [`current_model_vision`] returns `None` and the
//! gate falls back to the provider capability only — strictly additive.
tokio::task_local! {
/// User-configured vision capability for the currently-executing
/// session/sub-agent's model. Scoped per turn by the turn loop + subagent
/// runner. `None` when unset.
pub static CURRENT_MODEL_VISION: bool;
}
/// Returns the current model's user-configured vision flag, if scope is active.
pub fn current_model_vision() -> Option<bool> {
CURRENT_MODEL_VISION.try_with(|v| *v).ok()
}
/// Run `future` with `vision` installed as the current model's vision flag.
/// Intended call site is around each `run_turn_engine` invocation.
pub async fn with_current_model_vision<F, R>(vision: bool, future: F) -> R
where
F: std::future::Future<Output = R>,
{
CURRENT_MODEL_VISION.scope(vision, future).await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn current_model_vision_returns_none_outside_scope() {
assert_eq!(current_model_vision(), None);
}
#[tokio::test]
async fn with_current_model_vision_installs_value() {
let observed = with_current_model_vision(true, async { current_model_vision() }).await;
assert_eq!(observed, Some(true));
}
#[tokio::test]
async fn with_current_model_vision_does_not_leak_across_scopes() {
with_current_model_vision(true, async {
assert_eq!(current_model_vision(), Some(true));
})
.await;
assert_eq!(current_model_vision(), None);
}
#[tokio::test]
async fn nested_scope_overrides_outer() {
with_current_model_vision(false, async {
assert_eq!(current_model_vision(), Some(false));
with_current_model_vision(true, async {
assert_eq!(current_model_vision(), Some(true));
})
.await;
assert_eq!(current_model_vision(), Some(false));
})
.await;
}
}
@@ -80,10 +80,9 @@ pub struct SummarizedPayload {
/// agent history. Implementations decide the threshold, the dispatch
/// mechanism, and the failure policy.
///
/// Wired into the tool-execution sites in
/// [`super::tool_loop::run_tool_call_loop`] and
/// Wired into the tool-execution site in
/// [`crate::openhuman::agent::harness::session::Agent::execute_tool_call`]
/// via an `Option<&dyn PayloadSummarizer>` parameter so legacy callers
/// via an `Option<&dyn PayloadSummarizer>` parameter so callers
/// (CLI, REPL, tests, non-orchestrator sub-agents) can pass `None` and
/// keep the existing pass-through behaviour.
#[async_trait]
@@ -371,6 +370,7 @@ mod tests {
delegate_name: None,
agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker,
source: DefinitionSource::Builtin,
graph: Default::default(),
}
}
-290
View File
@@ -1,290 +0,0 @@
//! Self-healing interceptor — auto-polyfill when commands are missing.
//!
//! When the Code Executor's shell tool returns "command not found" or similar,
//! the interceptor spawns a ToolMaker sub-agent to write a polyfill script,
//! then retries the original command.
use crate::openhuman::tools::ToolResult;
use std::path::{Path, PathBuf};
/// Maximum number of self-heal attempts per unique command.
const MAX_HEAL_ATTEMPTS: u8 = 2;
/// Patterns in tool error output that indicate a missing command/binary.
const MISSING_CMD_PATTERNS: &[&str] = &[
"command not found",
": not found",
"not installed",
"No such file or directory",
"not recognized as an internal or external command",
"is not recognized",
"unable to find",
];
/// Interceptor that detects missing-command errors and spawns ToolMaker agents.
pub struct SelfHealingInterceptor {
/// Directory where polyfill scripts are written.
polyfill_dir: PathBuf,
/// Whether self-healing is enabled.
enabled: bool,
/// Track heal attempts per command to enforce MAX_HEAL_ATTEMPTS.
attempts: std::collections::HashMap<String, u8>,
}
impl SelfHealingInterceptor {
pub fn new(workspace_dir: &Path, enabled: bool) -> Self {
let polyfill_dir = workspace_dir.join("polyfills");
Self {
polyfill_dir,
enabled,
attempts: std::collections::HashMap::new(),
}
}
/// Check if a tool result indicates a missing command that can be self-healed.
///
/// Returns `Some(command_name)` if the error matches a known missing-command pattern
/// and we haven't exceeded the retry limit.
pub fn detect_missing_command(&mut self, result: &ToolResult) -> Option<String> {
if !self.enabled || !result.is_error {
return None;
}
let output_text = result.output().to_lowercase();
let combined = output_text;
// Check if the error matches any missing-command pattern.
let is_missing = MISSING_CMD_PATTERNS
.iter()
.any(|pattern| combined.contains(&pattern.to_lowercase()));
if !is_missing {
return None;
}
// Try to extract the command name from the error.
let cmd = extract_command_name(&combined)?;
// Check retry limit.
let count = self.attempts.entry(cmd.clone()).or_insert(0);
if *count >= MAX_HEAL_ATTEMPTS {
tracing::debug!(
"[self-healing] max attempts ({MAX_HEAL_ATTEMPTS}) reached for command: {cmd}"
);
return None;
}
*count += 1;
tracing::info!(
"[self-healing] detected missing command: {cmd} (attempt {}/{})",
*count,
MAX_HEAL_ATTEMPTS
);
Some(cmd)
}
/// Build the prompt for the ToolMaker sub-agent.
pub fn tool_maker_prompt(&self, missing_command: &str, original_context: &str) -> String {
format!(
"The command `{missing_command}` is not available in this environment.\n\
\n\
Write a polyfill script that accomplishes the equivalent functionality.\n\
Save it to: {polyfill_dir}/{missing_command}\n\
Make it executable with `chmod +x`.\n\
\n\
Original context:\n{original_context}\n\
\n\
Requirements:\n\
- Use only standard tools likely available (bash, python3, awk, sed, curl).\n\
- The script should accept the same arguments as the original command.\n\
- Keep it minimal — just enough to accomplish the immediate task.\n\
- Do NOT install packages or use sudo.",
polyfill_dir = self.polyfill_dir.display()
)
}
/// Get the polyfill directory path.
pub fn polyfill_dir(&self) -> &Path {
&self.polyfill_dir
}
/// Ensure the polyfill directory exists.
pub async fn ensure_polyfill_dir(&self) -> anyhow::Result<()> {
if !self.polyfill_dir.exists() {
tokio::fs::create_dir_all(&self.polyfill_dir).await?;
tracing::debug!(
"[self-healing] created polyfill directory: {}",
self.polyfill_dir.display()
);
}
Ok(())
}
/// Reset attempt counters (e.g. between sessions).
pub fn reset(&mut self) {
self.attempts.clear();
}
}
/// Try to extract a command name from an error message.
///
/// Handles patterns like:
/// - "bash: foo: command not found"
/// - "sh: 1: foo: not found"
/// - "'foo' is not recognized"
fn extract_command_name(error: &str) -> Option<String> {
// Pattern: "bash: CMD: command not found"
if let Some(idx) = error.find(": command not found") {
let before = &error[..idx];
if let Some(colon_idx) = before.rfind(": ") {
let cmd = before[colon_idx + 2..].trim();
if !cmd.is_empty() && cmd.len() < 64 {
return Some(cmd.to_string());
}
}
// Try without preceding colon.
let cmd = before.trim();
if let Some(last_word) = cmd.split_whitespace().last() {
if last_word.len() < 64 {
return Some(last_word.to_string());
}
}
}
// Pattern: "sh: N: CMD: not found"
if error.contains(": not found") {
let parts: Vec<&str> = error.split(':').collect();
if parts.len() >= 3 {
let candidate = parts[parts.len() - 2].trim();
if !candidate.is_empty()
&& candidate.len() < 64
&& !candidate.chars().all(|c| c.is_ascii_digit())
{
return Some(candidate.to_string());
}
}
}
// Pattern: "'CMD' is not recognized"
if error.contains("is not recognized") {
let stripped = error.replace(['\'', '"'], "");
if let Some(cmd) = stripped.split_whitespace().next() {
if cmd.len() < 64 {
return Some(cmd.to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn make_error_result(error: &str) -> ToolResult {
ToolResult::error(error)
}
#[test]
fn detects_bash_command_not_found() {
let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true);
let result = make_error_result("bash: jq: command not found");
let cmd = interceptor.detect_missing_command(&result);
assert_eq!(cmd, Some("jq".to_string()));
}
#[test]
fn detects_sh_not_found() {
let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true);
let result = make_error_result("sh: 1: nmap: not found");
let cmd = interceptor.detect_missing_command(&result);
assert_eq!(cmd, Some("nmap".to_string()));
}
#[test]
fn respects_max_attempts() {
let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true);
let result = make_error_result("bash: jq: command not found");
// First two attempts should succeed.
assert!(interceptor.detect_missing_command(&result).is_some());
assert!(interceptor.detect_missing_command(&result).is_some());
// Third should be None (max attempts reached).
assert!(interceptor.detect_missing_command(&result).is_none());
}
#[test]
fn ignores_successful_results() {
let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true);
let result = ToolResult::success("command not found"); // misleading output
assert!(interceptor.detect_missing_command(&result).is_none());
}
#[test]
fn disabled_returns_none() {
let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), false);
let result = make_error_result("bash: jq: command not found");
assert!(interceptor.detect_missing_command(&result).is_none());
}
#[test]
fn reset_clears_attempts() {
let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true);
let result = make_error_result("bash: jq: command not found");
interceptor.detect_missing_command(&result);
interceptor.detect_missing_command(&result);
interceptor.reset();
// After reset, should detect again.
assert!(interceptor.detect_missing_command(&result).is_some());
}
#[test]
fn tool_maker_prompt_includes_command() {
let interceptor = SelfHealingInterceptor::new(Path::new("/workspace"), true);
let prompt = interceptor.tool_maker_prompt("jq", "parse json output");
let normalized = prompt.replace('\\', "/");
assert!(normalized.contains("jq"));
assert!(normalized.contains("/workspace/polyfills/jq"));
assert!(normalized.contains("parse json output"));
}
#[test]
fn detects_windows_not_recognized_pattern() {
let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true);
let result = make_error_result("'rg' is not recognized as an internal or external command");
let cmd = interceptor.detect_missing_command(&result);
assert_eq!(cmd, Some("rg".to_string()));
}
#[test]
fn ignores_non_matching_or_malformed_missing_command_patterns() {
let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true);
assert!(interceptor
.detect_missing_command(&make_error_result("permission denied"))
.is_none());
let too_long = format!("bash: {}: command not found", "x".repeat(80));
assert!(interceptor
.detect_missing_command(&make_error_result(&too_long))
.is_none());
assert_eq!(extract_command_name("sh: 1: 1234: not found"), None);
}
#[tokio::test]
async fn ensure_polyfill_dir_creates_directory_and_exposes_path() {
let workspace = tempfile::TempDir::new().expect("temp workspace");
let interceptor = SelfHealingInterceptor::new(workspace.path(), true);
assert!(!interceptor.polyfill_dir().exists());
interceptor
.ensure_polyfill_dir()
.await
.expect("polyfill dir should be created");
assert!(interceptor.polyfill_dir().exists());
assert!(interceptor.polyfill_dir().ends_with("polyfills"));
}
}
@@ -182,9 +182,7 @@ pub(super) async fn run_agent_tool_call(
};
let policy = tool.timeout_policy(&call.arguments);
let (tool_deadline, timeout_secs) =
crate::openhuman::agent::harness::engine::tools::resolve_tool_deadline(
policy,
);
crate::openhuman::tool_timeout::resolve_tool_deadline(policy);
match policy {
crate::openhuman::tools::traits::ToolTimeout::Secs(req) => {
tracing::debug!(
@@ -446,18 +446,11 @@ impl Agent {
// baseline behaviour is identical to the legacy
// `create_intelligent_routing_provider` path.
//
// What we deliberately lose for now: the ReliableProvider retry
// wrapper, model_routes translation, and intelligent local/cloud
// task hinting that the legacy router added on top of the raw
// backend. Those are valuable but orthogonal — they can be layered
// back on top of the factory's output in a follow-up without
// re-introducing the routing bypass.
let _ = provider::ProviderRuntimeOptions {
auth_profile_override: None,
openhuman_dir: config.config_path.parent().map(std::path::PathBuf::from),
secrets_encrypt: config.secrets.encrypt,
reasoning_enabled: config.runtime.reasoning_enabled,
};
// The ReliableProvider retry/backoff + model-fallback wrapper is
// re-layered on top of the factory's resolved backend below (issue
// #4249, 1c). `model_routes` translation and intelligent local/cloud
// task hinting now live in the unified routing layer (router.rs) rather
// than a per-session wrapper, so they are not re-wrapped here.
// Explicit `hint:<role>` and known-tier model strings route to the
// matching workload (so a subagent declaring `hint:reasoning` still
// gets the user's `reasoning_provider`). Everything else — including
@@ -476,8 +469,23 @@ impl Agent {
// `chat_provider` selection. Subagents still set their own role
// through `ModelSpec::Hint(...)` in the subagent runner.
let provider_role = provider_role_for(agent_id, config.default_model.as_deref());
let (provider, mut model_name): (Box<dyn Provider>, String) =
let (raw_provider, mut model_name): (Box<dyn Provider>, String) =
crate::openhuman::inference::provider::create_chat_provider(provider_role, config)?;
// Re-layer the ReliableProvider retry/backoff + model-fallback wrapper on
// top of the factory's resolved backend (issue #4249, 1c). The migration to
// `create_chat_provider` dropped this; restore it so rate-limit/5xx retries
// and the user's `model_fallbacks` apply to the main chat turn exactly as
// the legacy `create_intelligent_routing_provider` path did. Capability
// probes (`supports_native_tools` / `supports_vision`) forward to the inner
// backend, so downstream dispatcher/vision selection is unchanged.
let provider: Box<dyn Provider> = Box::new(
crate::openhuman::inference::provider::reliable::ReliableProvider::new(
vec![(provider_role.to_string(), raw_provider)],
config.reliability.provider_retries,
config.reliability.provider_backoff_ms,
)
.with_model_fallbacks(config.reliability.model_fallbacks.clone()),
);
log::info!(
"[session-builder] agent_id={} provider_role={} resolved_model={} supports_native_tools={}",
agent_id,
@@ -1210,7 +1218,6 @@ impl Agent {
builder = builder.payload_summarizer(ps);
}
builder = builder.archivist_hook(archivist_hook_arc);
builder = builder.unified_compaction_enabled(config.learning.unified_compaction_enabled);
let mut agent = builder.build()?;
let connected_integrations_initialized = prewarmed_integrations.is_some();
agent.connected_integrations = prewarmed_integrations.unwrap_or_default();
@@ -9,7 +9,7 @@ use crate::openhuman::agent::harness::TriggerMemoryAgent;
use crate::openhuman::agent::memory_loader::DefaultMemoryLoader;
use crate::openhuman::agent_tool_policy::ToolPolicyEngine;
use crate::openhuman::config::ContextConfig;
use crate::openhuman::context::{ContextManager, ProviderSummarizer, SegmentRecapSummarizer};
use crate::openhuman::context::ContextManager;
use crate::openhuman::memory::Memory;
use crate::openhuman::tools::{Tool, ToolSpec};
use anyhow::Result;
@@ -49,7 +49,6 @@ impl AgentBuilder {
tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression::Full,
tool_policy: None,
archivist_hook: None,
unified_compaction_enabled: true,
}
}
@@ -351,21 +350,6 @@ impl AgentBuilder {
self
}
/// Phase 1.5 — gate the unified compaction path.
///
/// When `true` (the default) and an archivist hook is wired in via
/// [`Self::archivist_hook`], the session's `ContextManager` summarizer is
/// wrapped with a [`SegmentRecapSummarizer`] that routes autocompaction
/// through the archivist's rolling recap (one LLM summarizer, soft-fallback
/// to [`ProviderSummarizer`] when the recap is unavailable).
///
/// When `false` the `ProviderSummarizer` is used directly and Phase 1.5 is
/// completely absent from the hot path — behaviour is identical to today's.
pub fn unified_compaction_enabled(mut self, enabled: bool) -> Self {
self.unified_compaction_enabled = enabled;
self
}
/// Set the per-agent TokenJuice tool-output compression profile.
pub fn tokenjuice_compression(
mut self,
@@ -457,57 +441,12 @@ impl AgentBuilder {
// model's context window" routes through this single handle.
let context_config = self.context_config.unwrap_or_default();
// Phase 1.5 — unified compaction.
//
// When `unified_compaction_enabled` is true AND an archivist hook
// is wired in, wrap the inner `ProviderSummarizer` with a
// `SegmentRecapSummarizer`. The outer type:
// 1. Tries the rolling segment recap from the open segment.
// 2. Falls back to the inner `ProviderSummarizer` if unavailable.
//
// With the flag off OR no archivist, the plain `ProviderSummarizer`
// is used and Phase 1.5 is completely absent from the hot path
// — behaviour is identical to Phase 1.
let inner_summarizer: Arc<dyn crate::openhuman::context::Summarizer> =
Arc::new(ProviderSummarizer::new(provider.clone()));
let session_id_for_recap = self
.event_session_id
.clone()
.unwrap_or_else(|| "standalone".to_string());
let summarizer: Arc<dyn crate::openhuman::context::Summarizer> =
if self.unified_compaction_enabled {
if let Some(ref archivist) = self.archivist_hook {
log::debug!(
"[agent::builder] unified_compaction_enabled=true — \
wrapping summarizer with SegmentRecapSummarizer \
session_id={session_id_for_recap}"
);
Arc::new(SegmentRecapSummarizer::new(
Arc::clone(archivist),
session_id_for_recap,
inner_summarizer,
))
} else {
log::debug!(
"[agent::builder] unified_compaction_enabled=true but \
no archivist hook — using ProviderSummarizer"
);
inner_summarizer
}
} else {
log::debug!(
"[agent::builder] unified_compaction_enabled=false — \
using ProviderSummarizer (Phase 1.5 disabled)"
);
inner_summarizer
};
let context = ContextManager::new(
&context_config,
summarizer,
model_name.clone(),
prompt_builder,
);
// Live history reduction moved to the tinyagents graph
// (`ContextCompressionMiddleware` + `MessageTrimMiddleware`, issue
// #4249), so the session no longer constructs an in-turn summarizer
// here. The archivist hook still drives durable segment recaps on its
// own post-turn path; it is no longer coupled to context compaction.
let context = ContextManager::new(&context_config, prompt_builder);
let workspace_dir = self
.workspace_dir
@@ -27,7 +27,6 @@ mod runtime;
pub(crate) mod transcript;
mod turn;
mod turn_checkpoint;
mod turn_engine_adapter;
mod types;
pub use migration::{migrate_session_layout_if_needed, MigrationOutcome};
+349 -363
View File
@@ -1,6 +1,5 @@
//! Core turn execution: the main `turn()` method and `inject_agent_experience_context()`.
use super::super::turn_engine_adapter::{AgentCheckpoint, AgentObserver, AgentToolSource};
use super::super::types::Agent;
use super::{
integration_announcement_note, mcp_announcement_note, newly_connected_slugs,
@@ -15,9 +14,7 @@ use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::agent_experience::{
prepend_experience_block, render_experience_hits, AgentExperienceStore, ExperienceQuery,
};
use crate::openhuman::inference::provider::{
ChatMessage, ConversationMessage, AGENT_TURN_MAX_OUTPUT_TOKENS,
};
use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage};
use crate::openhuman::memory::MemoryCategory;
use crate::openhuman::util::truncate_with_ellipsis;
@@ -59,20 +56,33 @@ 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
// `parse_context_bundle_has_enough_context` moved to
// `tinyagents::middleware` alongside the `SuperContextMiddleware` graph node
// that now owns the first-turn context-collection pass (#4249).
/// Flatten the assistant tool calls a turn produced into [`ToolCallRecord`]s for
/// the deterministic cap checkpoint (it lists the tools that ran). Tool success
/// isn't tracked per call here, so each is recorded optimistically; the listing
/// is a human-readable fallback, not authoritative accounting.
fn tool_records_from_conversation(
conversation: &[ConversationMessage],
) -> Vec<hooks::ToolCallRecord> {
let mut records = Vec::new();
for msg in conversation {
if let ConversationMessage::AssistantToolCalls { tool_calls, .. } = msg {
for call in tool_calls {
records.push(hooks::ToolCallRecord {
name: call.name.clone(),
arguments: serde_json::from_str(&call.arguments)
.unwrap_or(serde_json::Value::Null),
success: true,
output_summary: String::new(),
duration_ms: 0,
});
}
}
}
records
}
fn render_agent_context_status_note(sources: &[harness::AgentContextPreparedSource]) -> String {
@@ -588,70 +598,24 @@ impl Agent {
.cached_transcript_messages
.as_ref()
.is_some_and(|msgs| msgs.iter().any(|m| m.role == "assistant"));
let enriched = if should_run_super_context(
// The scout no longer runs here imperatively: super context is now a
// before_model **graph node** (`SuperContextMiddleware`, installed via
// `context_mw.super_context` below). It runs the read-only `context_scout`
// on the first model call, folds the `[context_bundle]` into the user
// message, and registers its prepared-context source live so a later
// `agent_prepare_context` call self-suppresses. We only decide *whether*
// to enable the node here (the gate is unchanged).
let run_super_context = should_run_super_context(
self.agent_definition_id == "orchestrator",
first_turn,
has_prior_conversation,
self.context.super_context_enabled(),
) {
);
if run_super_context {
log::info!(
"[agent_loop] super_context enabled — running harness-driven context collection (new thread, first turn)"
"[agent_loop] super_context enabled — installing the SuperContextMiddleware graph node (new thread, first turn)"
);
let scout = harness::with_parent_context(parent_context.clone(), {
let user_message = user_message.to_string();
async move {
crate::openhuman::agent_orchestration::tools::run_context_scout(
&user_message,
None,
)
.await
}
})
.await;
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()
);
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; do not call `agent_prepare_context` \
again for general preparation.\n\n\
{bundle}\n\n---\n\n{enriched}"
)
}
Ok(result) => {
// No usable bundle: leave `agent_context_prepared_sources`
// untouched. Recording a marker here would (a) make
// `render_agent_context_status_note` tell the model to "use
// the prepared context below" when none was injected, and
// (b) suppress `agent_prepare_context` for the rest of the
// turn — blocking a legitimate retry by any path that still
// exposes the tool. The dedup only needs to hold once a
// bundle was actually injected (the success arm above).
log::warn!(
"[agent_loop] super_context scout returned an error — proceeding without bundle: {}",
result.output()
);
enriched
}
Err(err) => {
log::warn!(
"[agent_loop] super_context collection failed — proceeding without bundle: {err}"
);
enriched
}
}
} else {
enriched
};
}
let enriched = if agent_context_prepared_sources.is_empty() {
enriched
@@ -720,266 +684,19 @@ impl Agent {
self.session_key.clone(),
),
);
let mut tool_source = AgentToolSource {
tools: self.tools.clone(),
visible_tool_names: self.visible_tool_names.clone(),
tool_policy_session: self.tool_policy_session.clone(),
tool_policy: self.tool_policy.clone(),
payload_summarizer: self.payload_summarizer.clone(),
event_session_id: self.event_session_id().to_string(),
event_channel: self.event_channel().to_string(),
agent_definition_id: self.agent_definition_id.clone(),
prefer_markdown: self.context.prefer_markdown_tool_output(),
budget_bytes: self.context.tool_result_budget_bytes(),
compaction_enabled: self.context.compaction_enabled(),
tokenjuice_compression: self.tokenjuice_compression,
artifact_store: artifact_store.clone(),
should_send_specs: self.tool_dispatcher.should_send_tool_specs(),
advertised_specs: self.visible_tool_specs.as_ref().clone(),
records: Vec::new(),
};
let progress = super::super::super::engine::TurnProgress::new(self.on_progress.clone());
let parser = super::super::super::engine::DispatcherParser {
dispatcher: dispatcher.as_ref(),
};
let checkpoint = AgentCheckpoint {
provider: self.provider.clone(),
dispatcher: self.tool_dispatcher.clone(),
model: effective_model.clone(),
// The whole turn runs through the tinyagents harness (issue #4249);
// the legacy `run_turn_engine` has been removed. Heap-allocate the
// (large) session-turn future so it isn't held inline on `turn()`'s
// already-large frame — `run_single` and the cron wrappers nest more
// layers on top, which would otherwise overflow the stack.
Box::pin(self.run_turn_via_tinyagents_session(
user_message,
&effective_model,
temperature,
on_progress: self.on_progress.clone(),
user_message: user_message.to_string(),
max_iterations,
};
let turn_run_queue = self.run_queue.clone();
let cached_prefix = self.cached_transcript_messages.take();
// Resolve the context window once per turn through the provider so
// local providers (LM Studio) trim to their runtime-loaded n_ctx
// rather than the trained-max table (#3550 / TAURI-RUST-6V0).
// Must run before `agent: self` takes the &mut borrow below.
//
// For local providers this is always `Some` (a conservative floor
// backs up any missing profile default), so trimming always engages.
// `None` means a cloud provider with an unknown model — trimming is
// intentionally skipped there (large window; over-trimming is worse).
let turn_context_window = self
.provider
.effective_context_window(&effective_model)
.await;
match turn_context_window {
Some(context_window) => tracing::debug!(
provider = %provider_name,
model = %effective_model,
context_window,
"[agent_loop] effective context window resolved for turn"
),
None => tracing::debug!(
provider = %provider_name,
model = %effective_model,
"[agent_loop] effective context window unavailable (cloud unknown model); pre-dispatch trimming skipped this turn"
),
}
let mut observer = AgentObserver {
agent: self,
artifact_store,
effective_model: effective_model.clone(),
context_window: turn_context_window,
cumulative_input: 0,
cumulative_output: 0,
cumulative_cached: 0,
cumulative_charged: 0.0,
last_turn_usage: None,
cached_prefix,
pending_results: Vec::new(),
did_push_final: false,
};
let mut buf: Vec<ChatMessage> = Vec::new();
// Box-pin the parent agent's engine call so its ~600-line
// generator state lives on the heap. Tools that delegate to
// sub-agents (orchestrator → researcher / personality /
// archetype / skill) recurse back into another
// `run_turn_engine` via `run_subagent`; without the box,
// both engines' state machines pile up on the same tokio
// worker stack and overflow the 2 MiB default. The inner
// boxes inside `run_typed_mode` aren't reached if the
// overflow happens during the parent's poll on the way in
// — verified against the `chat-harness-subagent` Playwright
// lane crash on PR #3151.
// Carry the current turn's image placeholders so a delegation to the
// vision sub-agent (analyze_image) can forward the attached image
// into its prompt — the orchestrator's own non-vision turn keeps the
// placeholder as text and never rehydrates it.
let turn_image_placeholders =
crate::openhuman::agent::multimodal::extract_image_placeholders_in_text(
user_message,
);
let (outcome_result, subagent_usage_entries) =
crate::openhuman::tokenjuice::savings::with_turn_model(
effective_model.clone(),
// Box the per-turn context chain onto the heap so the added
// `with_turn_model` scope does not deepen the worker stack —
// the same stack-accumulation guard the sub-agent path uses
// around `run_turn_engine`. Without this the cron agent-job
// lib test overflows its stack under llvm-cov instrumentation
// (issue #4122 review).
Box::pin(
super::super::super::turn_subagent_usage::with_turn_collector(
super::super::super::turn_attachments_context::with_current_turn_image_placeholders(
turn_image_placeholders,
super::super::super::model_vision_context::with_current_model_vision(
model_vision,
Box::pin(super::super::super::engine::run_turn_engine(
provider.as_ref(),
&mut buf,
&mut tool_source,
&progress,
&mut observer,
&checkpoint,
&parser,
&provider_name,
&effective_model,
temperature,
true, // silent — the channel/UI renders via progress + the return value
&multimodal,
&multimodal_files,
max_iterations,
AGENT_TURN_MAX_OUTPUT_TOKENS,
None, // the web bridge streams via on_progress deltas, not on_delta
&[],
turn_run_queue,
None, // main agent compacts via its ContextManager in before_dispatch
)),
),
),
)
),
)
.await;
let outcome = outcome_result?;
// Pull the observer's accounting out, then drop it to release the
// `&mut self` borrow so the epilogue can use `self`.
let did_push_final = observer.did_push_final;
let mut cumulative_input = observer.cumulative_input;
let mut cumulative_output = observer.cumulative_output;
let mut cumulative_cached = observer.cumulative_cached;
let mut cumulative_charged = observer.cumulative_charged;
let last_turn_usage = observer.last_turn_usage.take();
drop(observer);
// Roll any sub-agent spend gathered during this turn into the
// session-level token/cost meters so the UI footer reflects the
// *holistic* cost (parent + delegated children). The global cost
// tracker is fed separately, per provider call, by each sub-agent's
// observer. `subagent_usage_entries` is also forwarded to the
// `chat_done` event for the per-child hover breakdown.
if !subagent_usage_entries.is_empty() {
let mut sub_input = 0u64;
let mut sub_output = 0u64;
let mut sub_cached = 0u64;
let mut sub_charged = 0.0f64;
for entry in &subagent_usage_entries {
sub_input += entry.usage.input_tokens;
sub_output += entry.usage.output_tokens;
sub_cached += entry.usage.cached_input_tokens;
sub_charged += entry.usage.charged_amount_usd;
}
tracing::debug!(
subagents = subagent_usage_entries.len(),
sub_input,
sub_output,
sub_charged,
"[agent_loop] folding sub-agent spend into turn totals"
);
cumulative_input += sub_input;
cumulative_output += sub_output;
cumulative_cached += sub_cached;
cumulative_charged += sub_charged;
}
// Capture the turn's holistic totals (parent + sub-agents) so the
// web-channel delivery layer can forward them on `chat_done` for the
// UI footer's session token / cost / context meters.
self.last_turn_usage_totals = Some(
crate::openhuman::agent::harness::turn_subagent_usage::LastTurnUsage {
input_tokens: cumulative_input,
output_tokens: cumulative_output,
cached_input_tokens: cumulative_cached,
cost_usd: cumulative_charged,
context_window: turn_context_window.unwrap_or(0),
subagents: subagent_usage_entries,
},
);
let records = std::mem::take(&mut tool_source.records);
self.context.record_tool_calls(records.len());
// Account this turn's tokens (prompt + completion) and elapsed time
// against the thread's active goal, flipping it to budget_limited
// when the cap is crossed. Best-effort — never fails the turn.
crate::openhuman::thread_goals::runtime::account_turn_against_goal(
&self.workspace_dir,
cumulative_input,
cumulative_output,
turn_started.elapsed().as_secs(),
)
.await;
// For a clean final response the observer already pushed the
// assistant message + persisted. For a max-iteration checkpoint or
// circuit-breaker halt the engine returned the text without pushing
// it, so finish the history + transcript here (mirrors the old
// final/max-iter branches).
if !did_push_final {
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
outcome.text.clone(),
)));
self.trim_history();
// Note: the engine already emits `TurnCompleted` on the
// checkpoint exit (and every other terminal path), so we don't
// re-emit it here — doing so would double-fire for the UI.
let messages = self.tool_dispatcher.to_provider_messages(&self.history);
self.persist_session_transcript(
&messages,
cumulative_input,
cumulative_output,
cumulative_cached,
cumulative_charged,
last_turn_usage.as_ref(),
);
}
// Auto-save a short memory of the final reply (not on a capped turn,
// matching the prior behavior).
if self.auto_save && outcome.stop != super::super::super::engine::TurnStop::Cap {
let summary = truncate_with_ellipsis(&outcome.text, 100);
let _ = self
.memory
.store("", "assistant_resp", &summary, MemoryCategory::Daily, None)
.await;
}
// Fire post-turn hooks (non-blocking).
if !self.post_turn_hooks.is_empty() {
let ctx = TurnContext {
user_message: user_message.to_string(),
assistant_response: outcome.text.clone(),
tool_calls: records,
turn_duration_ms: turn_started.elapsed().as_millis() as u64,
session_id: Some(self.event_session_id.clone())
.filter(|session_id| !session_id.trim().is_empty()),
agent_id: Some(self.agent_definition_id.clone())
.filter(|agent_id| !agent_id.trim().is_empty()),
entrypoint: Some(self.event_channel.clone())
.filter(|entrypoint| !entrypoint.trim().is_empty()),
iteration_count: outcome.iterations as usize,
};
hooks::fire_hooks(&self.post_turn_hooks, ctx);
}
Ok(outcome.text)
run_super_context,
))
.await
}; // end of `turn_body` async block
// Run the turn body inside the parent-execution-context scope so
@@ -1002,12 +719,22 @@ impl Agent {
turn_stop_hooks.push(std::sync::Arc::new(hook));
}
}
// Surface this turn's image-attachment placeholders so a delegation to a
// vision sub-agent (which reads `current_turn_image_placeholders()` in
// `agent_orchestration::tools::dispatch`) can forward the user's attached
// image — the orchestrator itself keeps it as a text placeholder. Scoped
// around the harness turn (the delegating tool fires inside it).
let image_placeholders =
crate::openhuman::agent::multimodal::extract_image_placeholders_in_text(user_message);
let result = if turn_stop_hooks.is_empty() {
harness::with_parent_context(
parent_context,
harness::with_agent_context_prepared_sources(
agent_context_prepared_sources.clone(),
turn_body,
harness::turn_attachments_context::with_current_turn_image_placeholders(
image_placeholders,
turn_body,
),
),
)
.await
@@ -1016,9 +743,12 @@ impl Agent {
parent_context,
harness::with_agent_context_prepared_sources(
agent_context_prepared_sources.clone(),
crate::openhuman::agent::stop_hooks::with_stop_hooks(
turn_stop_hooks,
turn_body,
harness::turn_attachments_context::with_current_turn_image_placeholders(
image_placeholders,
crate::openhuman::agent::stop_hooks::with_stop_hooks(
turn_stop_hooks,
turn_body,
),
),
),
)
@@ -1060,6 +790,287 @@ impl Agent {
result
}
/// Drive a full chat turn through the `tinyagents` harness (issue #4249).
///
/// The frozen system+prior history is converted to provider messages, the
/// user turn appended, and the loop run over the agent's resolved tools. The
/// final reply + the user turn are recorded into `history`, the transcript
/// is persisted, and `TurnCompleted` is emitted so the UI stops spinning.
///
/// Full-fidelity with the legacy `run_turn_engine`: live tool-timeline /
/// text-delta progress and the cost/token footer are mirrored from the
/// harness event stream via the [`OpenhumanEventBridge`] (tinyagents 0.2.0),
/// `[IMAGE:…]`/`[FILE:…]` markers are expanded for the provider, and history
/// is trimmed to the provider's context window.
async fn run_turn_via_tinyagents_session(
&mut self,
user_message: &str,
effective_model: &str,
temperature: f64,
max_iterations: usize,
// Whether the super-context graph node should run this turn (gate decided
// by `should_run_super_context` in `turn()`, before the user row was
// pushed to history — so it can't be recomputed here).
run_super_context: bool,
) -> Result<String> {
let turn_started = std::time::Instant::now();
// This turn's stamped user message is already the last entry in
// `self.history` (pushed by `turn()` before the engine branch), so build
// the provider messages straight from history — do NOT push the user
// again. When a cached transcript prefix is present (a resumed session's
// KV-cache warm-up), prepend it and clear it so the first request reuses
// the cached prefix exactly once.
let mut messages = self.tool_dispatcher.to_provider_messages(&self.history);
if let Some(cached) = self.cached_transcript_messages.take() {
// The cached prefix already carries the system prompt + prior
// conversation, so drop the freshly-rendered leading system
// message(s) and append only this turn's new (user) messages.
let tail = messages
.into_iter()
.skip_while(|m| m.role == "system")
.collect::<Vec<_>>();
let mut combined = cached;
combined.extend(tail);
messages = combined;
}
// Multimodal prep (parity with the legacy engine): rehydrate image
// placeholders for vision-capable providers, then expand `[IMAGE:…]` /
// `[FILE:…]` markers into provider-ready content before dispatch. The
// expanded copy is provider-only and never persisted to `history`.
let multimodal = self
.integration_runtime_config
.as_ref()
.map(|c| c.multimodal.clone())
.unwrap_or_default();
let multimodal_files = self
.integration_runtime_config
.as_ref()
.map(|c| c.multimodal_files.clone())
.unwrap_or_default();
// Honor custom/BYOK vision models too: they can set `model_vision` even
// when the provider capability bit is false, and must still rehydrate
// `[IMAGE:…]` placeholders (else image chat silently degrades to text).
if (self.provider.supports_vision() || self.model_vision)
&& crate::openhuman::agent::multimodal::has_image_placeholders(&messages)
{
messages = crate::openhuman::agent::multimodal::rehydrate_image_placeholders(&messages);
}
let messages = crate::openhuman::agent::multimodal::prepare_messages_for_provider(
&messages,
&multimodal,
&multimodal_files,
)
.await
.map(|prepared| prepared.messages)
.unwrap_or(messages);
tracing::info!(
model = %effective_model,
max_iterations,
tools = self.tools.len(),
"[agent_loop] routing chat turn through the tinyagents harness"
);
// Resolve the provider's effective context window so the harness can
// trim long threads to budget (autocompaction parity).
let context_window = self
.provider
.effective_context_window(effective_model)
.await;
// Dispatch through the chat turn graph (this folder's `graph.rs`): a thin
// wrapper over the shared tinyagents seam that pins the chat path's fixed
// arguments (no child scope, no early-exit tools, graceful cap pause,
// per-turn output cap) and runs the context-window summarization step.
// Context middlewares sourced from this session's ContextManager: the
// per-tool-result byte cap + payload summarizer (after_tool), the
// cache-align warning and microcompact tool-body clearing (before_model).
let context_mw = crate::openhuman::tinyagents::TurnContextMiddleware {
tool_result_budget_bytes: self.context.tool_result_budget_bytes(),
payload_summarizer: self.payload_summarizer.clone(),
cache_align: self.context.compaction_enabled(),
microcompact_keep_recent: self.context.microcompact_keep_recent(),
// Honor the [context].enabled / autocompact_enabled opt-outs: when off,
// the summarization middleware is not installed (no summarizer tokens,
// no history rewrite).
autocompact_enabled: self.context.autocompact_enabled(),
// Super context (first-turn read-only context collection) as a graph
// node — enabled only when its gate passed above. The node runs the
// scout on the first model call and folds the bundle into the message.
super_context: run_super_context.then(|| {
crate::openhuman::tinyagents::SuperContextConfig {
user_message: user_message.to_string(),
}
}),
};
// Gather any sub-agent spend delegated during this turn (synchronous
// `spawn_subagent` runs inline on this task and records into the collector)
// so the turn's usage meters + the `chat_done` per-child breakdown include
// it — the collector scope the legacy engine installed.
let (outcome, subagent_usage_entries) =
crate::openhuman::agent::harness::turn_subagent_usage::with_turn_collector(
super::graph::run_chat_turn_graph(super::graph::ChatTurnGraph {
provider: self.provider.clone(),
model: effective_model.to_string(),
temperature,
messages,
tools: self.tools.clone(),
visible_tool_names: self.visible_tool_names.clone(),
max_iterations,
on_progress: self.on_progress.clone(),
context_window,
run_queue: self.run_queue.clone(),
context_mw,
}),
)
.await;
let outcome = outcome?;
// The stamped user turn is already in `self.history` (pushed by `turn()`),
// so append only the structured messages this turn produced — assistant
// tool calls + tool results + (for a clean finish) the final assistant —
// preserving tool-call history fidelity for the UI, persisted transcript,
// and the next turn's KV-cache prefix.
self.history.extend(outcome.conversation.iter().cloned());
// Token accounting for the turn (the cap checkpoint call below folds in
// its own usage).
// Seed from the turn outcome (the harness observed real usage incl. cached
// tokens and an estimated cost) rather than zero, so a normal non-cap turn
// persists real cost instead of $0. The cap-checkpoint branch below folds
// in its extra call's usage on top.
let mut input_tokens = outcome.input_tokens;
let mut output_tokens = outcome.output_tokens;
let mut cached_input_tokens = outcome.cached_input_tokens;
let mut charged_amount_usd = outcome.charged_amount_usd;
let reply = if outcome.hit_cap {
// The loop paused at the tool-call cap. Ask the model for a resumable
// checkpoint (tools disabled), falling back to a deterministic
// done/next summary so the thread never ends on a dangling tool
// cycle. Fold the extra call's usage into the turn accounting.
let base = self.tool_dispatcher.to_provider_messages(&self.history);
let (summary, summary_usage) = self
.summarize_iteration_checkpoint(
&base,
effective_model,
outcome.model_calls as u32 + 1,
)
.await;
if let Some(u) = summary_usage {
input_tokens += u.input_tokens;
output_tokens += u.output_tokens;
cached_input_tokens += u.cached_input_tokens;
charged_amount_usd += u.charged_amount_usd;
}
let checkpoint = if summary.trim().is_empty() {
super::super::turn_checkpoint::build_deterministic_checkpoint(
&tool_records_from_conversation(&outcome.conversation),
max_iterations,
)
} else {
summary
};
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
checkpoint.clone(),
)));
checkpoint
} else if outcome.text.trim().is_empty() && outcome.tool_calls == 0 {
// A completion with no text and no tool calls is never a valid final
// answer — surface it as an error instead of wedging the thread on a
// blank reply (bug-report-2026-05-26 A1, defect B).
return Err(anyhow::Error::new(
crate::openhuman::agent::error::AgentError::EmptyProviderResponse {
iteration: outcome.model_calls,
},
));
} else {
outcome.text.clone()
};
self.trim_history();
// Fold this turn's sub-agent spend into the cumulative meters and capture
// the holistic per-turn usage the web channel surfaces on `chat_done` (it
// calls `take_last_turn_usage_totals()` right after the turn). Without this
// the event reported `usage: None` despite the transcript being persisted
// with real numbers.
for entry in &subagent_usage_entries {
input_tokens = input_tokens.saturating_add(entry.usage.input_tokens);
output_tokens = output_tokens.saturating_add(entry.usage.output_tokens);
cached_input_tokens =
cached_input_tokens.saturating_add(entry.usage.cached_input_tokens);
charged_amount_usd += entry.usage.charged_amount_usd;
}
self.last_turn_usage_totals = Some(
crate::openhuman::agent::harness::turn_subagent_usage::LastTurnUsage {
input_tokens,
output_tokens,
cached_input_tokens,
cost_usd: charged_amount_usd,
context_window: context_window.unwrap_or(0),
subagents: subagent_usage_entries,
},
);
let persisted = self.tool_dispatcher.to_provider_messages(&self.history);
self.persist_session_transcript(
&persisted,
input_tokens,
output_tokens,
cached_input_tokens,
charged_amount_usd,
None,
);
// Charge this turn's usage against the thread's active goal (parity with
// the legacy engine) so budgeted goals progress to `budget_limited` and
// continuation scheduling reads a live budget. Self-guarding + best-effort
// — a no-op when there is no active goal for the ambient thread.
crate::openhuman::thread_goals::runtime::account_turn_against_goal(
&self.workspace_dir,
input_tokens,
output_tokens,
turn_started.elapsed().as_secs(),
)
.await;
self.emit_progress(AgentProgress::TurnCompleted {
iterations: outcome.model_calls as u32,
})
.await;
if self.auto_save {
let summary = truncate_with_ellipsis(&reply, 100);
let _ = self
.memory
.store("", "assistant_resp", &summary, MemoryCategory::Daily, None)
.await;
}
// Fire post-turn hooks (non-blocking), matching the legacy engine.
if !self.post_turn_hooks.is_empty() {
let ctx = TurnContext {
user_message: user_message.to_string(),
assistant_response: reply.clone(),
tool_calls: tool_records_from_conversation(&outcome.conversation),
turn_duration_ms: turn_started.elapsed().as_millis() as u64,
session_id: Some(self.event_session_id.clone())
.filter(|session_id| !session_id.trim().is_empty()),
agent_id: Some(self.agent_definition_id.clone())
.filter(|agent_id| !agent_id.trim().is_empty()),
entrypoint: Some(self.event_channel.clone())
.filter(|entrypoint| !entrypoint.trim().is_empty()),
iteration_count: outcome.model_calls,
};
hooks::fire_hooks(&self.post_turn_hooks, ctx);
}
Ok(reply)
}
pub(super) async fn inject_agent_experience_context(
&self,
user_message: &str,
@@ -1221,10 +1232,7 @@ impl Agent {
#[cfg(test)]
mod super_context_gate_tests {
use super::{
parse_context_bundle_has_enough_context, render_agent_context_status_note,
should_run_super_context,
};
use super::{render_agent_context_status_note, should_run_super_context};
use crate::openhuman::agent::harness::AgentContextPreparedSource;
#[test]
@@ -1290,26 +1298,4 @@ mod super_context_gate_tests {
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
);
}
}
@@ -0,0 +1,107 @@
//! The **chat turn graph** (issue #4249).
//!
//! Per the per-folder `graph.rs` convention, this module owns the chat folder's
//! graph definition, its available tools, and its summarization step — all thin
//! over the shared tinyagents seam
//! ([`run_turn_via_tinyagents_shared`](crate::openhuman::tinyagents::run_turn_via_tinyagents_shared)).
//!
//! **Graph.** The top-level interactive chat turn: a single agent-loop turn
//! driven by the tinyagents harness, observed via the session's `on_progress`
//! sink (live tool timeline, streaming text deltas, cost/token footer) and
//! steerable mid-flight through the session run queue. The loop pauses gracefully
//! at the model-call cap so [`core`](super::core) can emit a resumable checkpoint
//! instead of erroring.
//!
//! **Available tools.** The agent's resolved harness tool set (`tools`),
//! advertised via [`SharedToolAdapter`](crate::openhuman::tinyagents::SharedToolAdapter)
//! and filtered by `visible_tool_names`. The chat turn surfaces clarifying
//! questions inline rather than pausing, so it advertises **no early-exit
//! tools**.
//!
//! **Summarization.** The caller resolves the model's effective context window
//! and passes it as `context_window`, so the shared seam installs the
//! context-window summarization step (`tinyagents::summarize`) ahead of the
//! deterministic front-trim.
use std::collections::HashSet;
use std::sync::Arc;
use anyhow::Result;
use tokio::sync::mpsc::Sender;
use crate::openhuman::agent::harness::run_queue::RunQueue;
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::inference::provider::{ChatMessage, Provider, AGENT_TURN_MAX_OUTPUT_TOKENS};
use crate::openhuman::tinyagents::{
run_turn_via_tinyagents_shared, TinyagentsTurnOutcome, TurnContextMiddleware,
};
use crate::openhuman::tools::Tool;
/// Inputs for a single chat-turn graph dispatch. Grouped into a struct so the
/// thin entry point stays readable (the shared seam takes 14 positional args);
/// each field maps to the chat path's variable inputs while the fixed chat-path
/// arguments (no child scope, no early-exit tools, graceful cap pause, per-turn
/// output cap) are applied inside [`run_chat_turn_graph`].
pub(crate) struct ChatTurnGraph {
/// The session provider (already cloned by the caller).
pub provider: Arc<dyn Provider>,
/// The effective model id for this turn.
pub model: String,
/// Sampling temperature.
pub temperature: f64,
/// Provider-ready messages (system + prior history + this turn's user turn,
/// multimodal markers already expanded).
pub messages: Vec<ChatMessage>,
/// The agent's resolved, `Arc`-shared harness tool set.
pub tools: Arc<Vec<Box<dyn Tool>>>,
/// Callable-tool whitelist (empty = every visible tool).
pub visible_tool_names: HashSet<String>,
/// Model-call cap for the loop.
pub max_iterations: usize,
/// Session progress sink — mirrors the harness event stream onto
/// `AgentProgress` when `Some`.
pub on_progress: Option<Sender<AgentProgress>>,
/// Resolved context window, driving the summarization step. `None` when the
/// provider does not advertise a window.
pub context_window: Option<u64>,
/// Session run queue for mid-flight steering.
pub run_queue: Option<Arc<RunQueue>>,
/// openhuman context middlewares (cache-align, microcompact, tool-output
/// budget + payload summarizer) sourced from the session's `ContextManager`.
pub context_mw: TurnContextMiddleware,
}
/// Drive the chat turn graph: a thin wrapper over the shared tinyagents seam
/// that pins the chat path's fixed arguments. Returns the turn outcome
/// ([`core`](super::core) folds usage, persists the conversation, and handles a
/// cap-hit checkpoint).
pub(crate) async fn run_chat_turn_graph(graph: ChatTurnGraph) -> Result<TinyagentsTurnOutcome> {
run_turn_via_tinyagents_shared(
graph.provider,
&graph.model,
graph.temperature,
graph.messages,
vec![graph.tools],
graph.visible_tool_names,
graph.max_iterations,
// Mirror the harness event stream onto this session's progress sink.
graph.on_progress,
// Top-level chat turn — no child-progress attribution.
None,
graph.context_window,
// Mid-flight steering from the session's run queue.
graph.run_queue,
// The top-level chat turn surfaces clarifying questions inline rather
// than pausing the loop, so no early-exit tools here.
&[],
// Pause gracefully at the model-call cap so the turn emits a resumable
// checkpoint instead of erroring or returning a dangling tool cycle.
true,
// Bound the main agent's per-call output (legacy parity — the engine
// capped every turn at `AGENT_TURN_MAX_OUTPUT_TOKENS`).
Some(AGENT_TURN_MAX_OUTPUT_TOKENS),
// Context middlewares sourced from the session's ContextManager.
graph.context_mw,
)
.await
}
@@ -3,6 +3,7 @@
mod context;
mod core;
mod graph;
mod session_io;
mod tools;
@@ -1,561 +0,0 @@
//! Engine seams for the stateful `Agent::turn`.
//!
//! These adapt the `Agent` to the shared [`run_turn_engine`] so web/desktop
//! chat runs the same loop as every other entry point, while preserving the
//! Agent's richer state: typed `ConversationMessage` history (with structured
//! tool calls + round-tripped `reasoning_content`), the `ContextManager`
//! reduction chain, KV-cache transcript prefixes, transcript persistence, and
//! the pluggable `ToolDispatcher` (incl. PFormat).
//!
//! * [`AgentToolSource`] owns `Arc`/value clones of the Agent's tool state
//! (disjoint from the `&mut Agent` the observer holds) and runs each call
//! through the shared [`run_agent_tool_call`], collecting `ToolCallRecord`s.
//! * [`AgentObserver`] borrows the `Agent` mutably: it runs the context
//! reduction + re-materializes the engine's `ChatMessage` buffer from the
//! typed history each iteration, rebuilds the typed history from the engine's
//! per-iteration callbacks, accumulates usage, and persists the transcript.
//! * [`AgentCheckpoint`] summarizes the turn-so-far into a resumable checkpoint
//! when the iteration cap is hit (mirrors `summarize_iteration_checkpoint`).
use std::collections::HashSet;
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use super::agent_tool_exec::{run_agent_tool_call, AgentToolExecCtx};
use super::transcript;
use super::turn_checkpoint::MAX_ITER_CHECKPOINT_INSTRUCTION;
use super::types::Agent;
use crate::openhuman::agent::dispatcher::{
ParsedToolCall as DispatcherParsedToolCall, ToolDispatcher, ToolExecutionResult,
};
use crate::openhuman::agent::harness::engine::{
CheckpointOutcome, CheckpointStrategy, ProgressReporter, ToolRunResult, ToolSource,
TurnObserver,
};
use crate::openhuman::agent::harness::parse::ParsedToolCall;
use crate::openhuman::agent::harness::payload_summarizer::PayloadSummarizer;
use crate::openhuman::agent::harness::tool_result_artifacts::{
spill_aggregate_tool_results, ToolResultArtifactStore,
};
use crate::openhuman::agent::hooks::ToolCallRecord;
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::agent::tool_policy::ToolPolicy;
use crate::openhuman::agent_tool_policy::ToolPolicySession;
use crate::openhuman::context::ReductionOutcome;
use crate::openhuman::inference::provider::{
ChatMessage, ChatRequest, ConversationMessage, Provider, ProviderDelta, ToolCall, UsageInfo,
AGENT_TURN_MAX_OUTPUT_TOKENS,
};
use crate::openhuman::tools::{Tool, ToolSpec};
/// Rebuild the persisted `Vec<ToolCall>` for an assistant-with-tools history
/// entry: prefer the provider's native calls, else synthesise from the parsed
/// calls (mirrors `Agent::persisted_tool_calls_for_history`).
fn persisted_tool_calls(
native: &[ToolCall],
parsed: &[ParsedToolCall],
results: &[ToolExecutionResult],
iteration: usize,
) -> Vec<ToolCall> {
if !native.is_empty() {
return native.to_vec();
}
// Synthesise from the parsed calls, reusing the *exact* id each result was
// recorded under (`results[i].tool_call_id`) so the persisted assistant
// tool-call id matches its `ToolResults` entry — what the next provider
// turn (and history-fidelity tests) rely on.
parsed
.iter()
.enumerate()
.map(|(idx, c)| {
let id = results
.get(idx)
.and_then(|r| r.tool_call_id.clone())
.or_else(|| c.id.clone())
.unwrap_or_else(|| format!("parsed-{}-{}", iteration + 1, idx + 1));
ToolCall {
id,
name: c.name.clone(),
arguments: c.arguments.to_string(),
// Prompt-parsed calls carry no provider extra_content; the
// native (Gemini) path returns early above, preserving it.
extra_content: None,
}
})
.collect()
}
/// Tool source for `Agent::turn`. Owns clones of the Agent's tool state so it
/// doesn't borrow the `Agent` (which [`AgentObserver`] holds mutably).
pub(super) struct AgentToolSource {
pub tools: Arc<Vec<Box<dyn Tool>>>,
pub visible_tool_names: HashSet<String>,
pub tool_policy_session: ToolPolicySession,
pub tool_policy: Arc<dyn ToolPolicy>,
pub payload_summarizer: Option<Arc<dyn PayloadSummarizer>>,
pub event_session_id: String,
pub event_channel: String,
pub agent_definition_id: String,
pub prefer_markdown: bool,
pub budget_bytes: usize,
/// Stage 1a kill-switch. Constant for the session, so (unlike the tool
/// surface) it is set once at construction and never re-synced.
pub compaction_enabled: bool,
/// Agent-level TokenJuice profile. Constant for the session.
pub tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression,
pub artifact_store: Option<ToolResultArtifactStore>,
pub should_send_specs: bool,
pub advertised_specs: Vec<ToolSpec>,
/// Collected per-call records, drained by the post-loop epilogue for hooks.
pub records: Vec<ToolCallRecord>,
}
#[async_trait]
impl ToolSource for AgentToolSource {
fn request_specs(&self) -> &[ToolSpec] {
if self.should_send_specs {
&self.advertised_specs
} else {
&[]
}
}
async fn execute_call(
&mut self,
call: &ParsedToolCall,
iteration: usize,
progress: &dyn ProgressReporter,
_progress_call_id: &str,
) -> ToolRunResult {
// `run_agent_tool_call` takes the dispatcher's `ParsedToolCall` shape;
// convert from the engine's internal one.
let dispatcher_call = DispatcherParsedToolCall {
name: call.name.clone(),
arguments: call.arguments.clone(),
tool_call_id: call.id.clone(),
};
let ctx = AgentToolExecCtx {
tools: &self.tools,
visible_tool_names: &self.visible_tool_names,
tool_policy_session: &self.tool_policy_session,
tool_policy: self.tool_policy.as_ref(),
payload_summarizer: self.payload_summarizer.as_deref(),
event_session_id: &self.event_session_id,
event_channel: &self.event_channel,
agent_definition_id: &self.agent_definition_id,
prefer_markdown: self.prefer_markdown,
budget_bytes: self.budget_bytes,
compaction_enabled: self.compaction_enabled,
tokenjuice_compression: self.tokenjuice_compression,
artifact_store: self.artifact_store.as_ref(),
};
let (exec_result, record) =
run_agent_tool_call(&ctx, progress, &dispatcher_call, iteration).await;
self.records.push(record);
ToolRunResult {
text: exec_result.output,
success: exec_result.success,
}
}
fn sync_agent_surface(
&mut self,
tools: Arc<Vec<Box<dyn Tool>>>,
visible_tool_names: HashSet<String>,
tool_policy_session: ToolPolicySession,
payload_summarizer: Option<Arc<dyn PayloadSummarizer>>,
prefer_markdown: bool,
budget_bytes: usize,
should_send_specs: bool,
advertised_specs: Vec<ToolSpec>,
) {
self.tools = tools;
self.visible_tool_names = visible_tool_names;
self.tool_policy_session = tool_policy_session;
self.payload_summarizer = payload_summarizer;
self.prefer_markdown = prefer_markdown;
self.budget_bytes = budget_bytes;
self.should_send_specs = should_send_specs;
self.advertised_specs = advertised_specs;
}
}
/// Turn observer for `Agent::turn`: owns the typed-history rebuild, context
/// management, usage accounting, and transcript persistence.
pub(super) struct AgentObserver<'a> {
pub agent: &'a mut Agent,
pub artifact_store: Option<ToolResultArtifactStore>,
pub effective_model: String,
/// Effective context window (tokens) for `effective_model`, resolved once
/// per turn via the provider so local providers (e.g. LM Studio) trim to
/// their *runtime-loaded* `n_ctx` rather than the model's trained maximum
/// (#3550 / Sentry TAURI-RUST-6V0). `None` → skip pre-dispatch trimming.
pub context_window: Option<u64>,
pub cumulative_input: u64,
pub cumulative_output: u64,
pub cumulative_cached: u64,
pub cumulative_charged: f64,
pub last_turn_usage: Option<transcript::TurnUsage>,
/// Cached transcript prefix for KV-cache reuse on a resumed session,
/// consumed on the first iteration.
pub cached_prefix: Option<Vec<ChatMessage>>,
/// Tool results buffered during the per-call loop, flushed to typed history
/// via the dispatcher's `format_results` once the assistant turn lands.
pub pending_results: Vec<ToolExecutionResult>,
/// Whether the engine reported a clean final response (so the post-loop
/// epilogue knows not to push `outcome.text` itself).
pub did_push_final: bool,
}
impl AgentObserver<'_> {
fn persist(&mut self) {
let messages = self
.agent
.tool_dispatcher
.to_provider_messages(&self.agent.history);
self.agent.persist_session_transcript(
&messages,
self.cumulative_input,
self.cumulative_output,
self.cumulative_cached,
self.cumulative_charged,
self.last_turn_usage.as_ref(),
);
}
}
#[async_trait]
impl TurnObserver for AgentObserver<'_> {
async fn before_dispatch(
&mut self,
buf: &mut Vec<ChatMessage>,
tools: &mut dyn crate::openhuman::agent::harness::engine::ToolSource,
iteration: usize,
) -> Result<()> {
if self.agent.drain_composio_integrations_changed_events() {
let refreshed = self
.agent
.refresh_delegation_tools_from_cached_integrations("event");
if refreshed {
log::debug!(
"[agent_loop] midturn:resync-delegation-tools — composio integrations changed; resyncing tool surface (iteration={} visible_tools={})",
iteration,
self.agent.visible_tool_names.len()
);
tools.sync_agent_surface(
Arc::clone(&self.agent.tools),
self.agent.visible_tool_names.clone(),
self.agent.tool_policy_session.clone(),
self.agent.payload_summarizer.clone(),
self.agent.context.prefer_markdown_tool_output(),
self.agent.context.tool_result_budget_bytes(),
self.agent.tool_dispatcher.should_send_tool_specs(),
self.agent.visible_tool_specs.as_ref().clone(),
);
}
}
// Pre-dispatch token-budget trim on the typed history.
if let Some(context_window) = self.context_window {
super::super::token_budget::trim_conversation_history_to_budget(
&mut self.agent.history,
context_window,
);
}
// Global context-management reduction chain.
let outcome = self
.agent
.context
.reduce_before_call(&mut self.agent.history)
.await?;
if let ReductionOutcome::Exhausted {
utilisation_pct,
reason,
} = &outcome
{
return Err(anyhow::anyhow!(
"Context window exhausted ({utilisation_pct}% full): {reason}"
));
}
// Re-materialize the engine's ChatMessage buffer from the typed
// history. On the first iteration of a resumed session, splice the
// byte-identical cached prefix + the new user-message tail for KV-cache
// reuse; otherwise rebuild from scratch.
let messages = if let Some(mut cached) = self.cached_prefix.take() {
let tail = self.agent.tool_dispatcher.to_provider_messages(
&self.agent.history[self.agent.history.len().saturating_sub(1)..],
);
cached.extend(tail);
cached
} else {
self.agent
.tool_dispatcher
.to_provider_messages(&self.agent.history)
};
*buf = messages;
// Second-pass trim on the materialized provider messages (mirrors the
// legacy `Agent::turn`, which trimmed both the typed history and the
// built `ChatMessage` list).
if let Some(context_window) = self.context_window {
super::super::token_budget::trim_chat_messages_to_budget(buf, context_window);
}
Ok(())
}
fn allow_empty_final(&self) -> bool {
false
}
fn record_usage(&mut self, provider: &str, model: &str, usage: &UsageInfo) {
self.agent.context.record_usage(usage);
crate::openhuman::cost::record_provider_usage(model, usage);
// Effective per-call cost: the backend-charged amount when the provider
// echoes one, else the per-model catalog estimate (#4124). Using this
// instead of raw `charged_amount_usd` means BYO/local providers that
// never bill a charge still contribute a priced cost to the session
// total. `cumulative_charged` is therefore the *net cost*, not strictly
// the backend charge.
let call_cost = crate::openhuman::agent::cost::call_cost_usd(model, usage);
self.cumulative_input += usage.input_tokens;
self.cumulative_output += usage.output_tokens;
self.cumulative_cached += usage.cached_input_tokens;
self.cumulative_charged += call_cost;
self.last_turn_usage = Some(transcript::TurnUsage {
provider: provider.to_string(),
model: model.to_string(),
usage: transcript::MessageUsage {
input: usage.input_tokens,
output: usage.output_tokens,
cached_input: usage.cached_input_tokens,
context_window: usage.context_window,
cost_usd: call_cost,
},
ts: chrono::Utc::now().to_rfc3339(),
reasoning_content: None,
tool_calls: Vec::new(),
iteration: 0,
});
}
async fn on_assistant(
&mut self,
display_text: &str,
_response_text: &str,
reasoning_content: Option<&str>,
native_tool_calls: &[ToolCall],
parsed_calls: &[ParsedToolCall],
iteration: usize,
is_final: bool,
) {
if is_final {
let mut assistant_msg = ChatMessage::assistant(display_text.to_string());
if let Some(rc) = reasoning_content {
assistant_msg.extra_metadata = Some(serde_json::json!({ "reasoning_content": rc }));
}
let mut turn_usage = None;
if let Some(ref mut usage) = self.last_turn_usage {
usage.reasoning_content = reasoning_content
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string);
usage.tool_calls = native_tool_calls.to_vec();
usage.iteration = (iteration + 1) as u32;
turn_usage = Some(usage.clone());
}
if let Some(turn_usage) = turn_usage.as_ref() {
transcript::attach_turn_usage_metadata(&mut assistant_msg, turn_usage);
}
self.agent
.history
.push(ConversationMessage::Chat(assistant_msg));
self.agent.trim_history();
self.did_push_final = true;
return;
}
// Assistant turn with tool calls. Mirror `Agent::turn` exactly: push the
// pre-tool narrative text (if any) as a standalone Chat message, then
// the structured AssistantToolCalls, then the dispatcher-formatted
// results buffered during the per-call loop.
if !display_text.is_empty() {
self.agent
.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
display_text.to_string(),
)));
}
let tool_calls = persisted_tool_calls(
native_tool_calls,
parsed_calls,
&self.pending_results,
iteration,
);
if let Some(ref mut usage) = self.last_turn_usage {
usage.reasoning_content = reasoning_content
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string);
usage.tool_calls = tool_calls.clone();
usage.iteration = (iteration + 1) as u32;
}
let extra_metadata = self
.last_turn_usage
.as_ref()
.and_then(transcript::turn_usage_extra_metadata);
self.agent
.history
.push(ConversationMessage::AssistantToolCalls {
text: if display_text.is_empty() {
None
} else {
Some(display_text.to_string())
},
tool_calls,
reasoning_content: reasoning_content
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string),
extra_metadata,
});
let mut results = std::mem::take(&mut self.pending_results);
spill_aggregate_tool_results(
&mut results,
self.artifact_store.as_ref(),
self.agent.context.tool_result_budget_bytes(),
)
.await;
let formatted = self.agent.tool_dispatcher.format_results(&results);
self.agent.history.push(formatted);
self.agent.trim_history();
}
fn on_tool_result(
&mut self,
call_id: &str,
tool_name: &str,
result_text: &str,
success: bool,
_iteration: usize,
) {
self.pending_results.push(ToolExecutionResult {
name: tool_name.to_string(),
output: result_text.to_string(),
success,
tool_call_id: Some(call_id.to_string()),
});
}
fn after_iteration(&mut self, _buf: &[ChatMessage], _iteration: usize) {
self.persist();
}
}
/// Max-iteration checkpoint for `Agent::turn`: summarize the turn's tool digest
/// into a resumable checkpoint (streaming text deltas through the progress
/// sink), with a deterministic fallback.
pub(super) struct AgentCheckpoint {
pub provider: Arc<dyn Provider>,
pub dispatcher: Arc<dyn ToolDispatcher>,
pub model: String,
pub temperature: f64,
pub on_progress: Option<tokio::sync::mpsc::Sender<AgentProgress>>,
pub user_message: String,
pub max_iterations: usize,
}
#[async_trait]
impl CheckpointStrategy for AgentCheckpoint {
async fn on_max_iter(&self, digest: &str, max_iterations: usize) -> Result<CheckpointOutcome> {
let deterministic = format!(
"I reached the tool-call limit for this turn ({max_iterations} steps), so I paused here.\n\n\
**Done so far:**\n{digest}\n\
**Next steps:** I'll continue from here — just reply (e.g. \"continue\") and I'll pick up \
where I left off."
);
let mut messages = vec![ChatMessage::user(format!(
"You were working on this user request:\n{}\n\nHere are the tool calls you made this turn \
and their results — compile your checkpoint from these:\n{}",
self.user_message, digest
))];
messages.push(ChatMessage::user(MAX_ITER_CHECKPOINT_INSTRUCTION));
let checkpoint_iteration = (self.max_iterations + 1) as u32;
// Stream the checkpoint prose as text deltas (tools disabled).
let (delta_tx_opt, delta_forwarder) = if self.on_progress.is_some() {
let (tx, mut rx) = tokio::sync::mpsc::channel::<ProviderDelta>(128);
let progress_tx = self.on_progress.clone();
let forwarder = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
let Some(ref sink) = progress_tx else {
continue;
};
if let ProviderDelta::TextDelta { delta } = event {
if sink
.send(AgentProgress::TextDelta {
delta,
iteration: checkpoint_iteration,
})
.await
.is_err()
{
break;
}
}
}
});
(Some(tx), Some(forwarder))
} else {
(None, None)
};
let result = self
.provider
.chat(
ChatRequest {
messages: &messages,
tools: None,
stream: delta_tx_opt.as_ref(),
// Reservation-pricing pre-flight budget cap (TAURI-RUST-C62).
max_tokens: Some(AGENT_TURN_MAX_OUTPUT_TOKENS),
},
&self.model,
self.temperature,
)
.await;
drop(delta_tx_opt);
if let Some(handle) = delta_forwarder {
let _ = handle.await;
}
match result {
Ok(resp) => {
let usage = resp.usage.clone();
// Strip any stray tool-call markup; keep only prose.
let (text, calls) = self.dispatcher.parse_response(&resp);
let checkpoint = if !text.trim().is_empty() {
text
} else if calls.is_empty() {
resp.text.unwrap_or_default()
} else {
String::new()
};
let text = if checkpoint.trim().is_empty() {
deterministic
} else {
checkpoint
};
Ok(CheckpointOutcome { text, usage })
}
Err(e) => {
log::warn!("[agent_loop] checkpoint summary call failed: {e:#}");
Ok(CheckpointOutcome {
text: deterministic,
usage: None,
})
}
}
}
}
@@ -367,14 +367,6 @@ pub struct AgentBuilder {
/// `config.learning.episodic_capture_enabled` is true. Used to call
/// `flush_open_segment` at the closest available session-end signal.
pub(super) archivist_hook: Option<Arc<ArchivistHook>>,
/// Phase 1.5 — when `true` AND `archivist_hook` is `Some`, the
/// `ContextManager`'s summarizer is wrapped with a
/// `SegmentRecapSummarizer` that routes compaction through the
/// archivist's rolling segment recap (one summarizer, soft-fallback).
/// When `false` (or archivist absent), the plain `ProviderSummarizer`
/// is used and Phase 1.5 is completely absent from the hot path.
/// Default: `true` (mirrors `LearningConfig::unified_compaction_enabled`).
pub(super) unified_compaction_enabled: bool,
}
impl Default for AgentBuilder {
@@ -1,158 +0,0 @@
//! Per-session serialised lane queue.
//!
//! All incoming tasks are serialised per-session to prevent race conditions when
//! writing to files, memory, or other shared resources. Cross-session requests
//! run concurrently.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore};
/// A queue that serialises work within a session while allowing parallelism
/// across sessions.
///
/// Each session ID maps to a `Semaphore(1)`. Acquiring the permit blocks
/// subsequent requests for the *same* session until the permit is released.
pub struct SessionQueue {
lanes: Mutex<HashMap<String, Arc<Semaphore>>>,
}
impl SessionQueue {
pub fn new() -> Self {
Self {
lanes: Mutex::new(HashMap::new()),
}
}
/// Acquire the lane for `session_id`.
///
/// Returns an `OwnedSemaphorePermit` that the caller must hold for the
/// duration of the request. Subsequent requests on the same session will
/// block until this permit is dropped.
pub async fn acquire(&self, session_id: &str) -> OwnedSemaphorePermit {
let sem = {
let mut map = self.lanes.lock().await;
let is_new = !map.contains_key(session_id);
let sem = map
.entry(session_id.to_string())
.or_insert_with(|| Arc::new(Semaphore::new(1)))
.clone();
if is_new {
tracing::trace!("[session-queue] created lane for session={session_id}");
}
tracing::trace!(
"[session-queue] acquiring lane session={session_id}, permits={}",
sem.available_permits()
);
sem
};
let permit = sem.acquire_owned().await.expect("session semaphore closed");
tracing::trace!("[session-queue] acquired lane for session={session_id}");
permit
}
/// Remove stale session lanes that have no waiters.
/// Call periodically or after sessions end to prevent unbounded growth.
pub async fn gc(&self) {
let mut map = self.lanes.lock().await;
let before = map.len();
map.retain(|id, sem| {
let keep = sem.available_permits() < 1 || Arc::strong_count(sem) > 1;
if !keep {
tracing::trace!("[session-queue] pruning idle lane session={id}");
}
keep
});
let removed = before - map.len();
if removed > 0 {
tracing::debug!(
"[session-queue] gc removed {removed} idle lane(s), {} remaining",
map.len()
);
}
}
/// Number of tracked session lanes (for diagnostics).
pub async fn lane_count(&self) -> usize {
self.lanes.lock().await.len()
}
}
impl Default for SessionQueue {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::time::{sleep, Duration};
#[tokio::test]
async fn serialises_within_same_session() {
let queue = Arc::new(SessionQueue::new());
let counter = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..5 {
let q = queue.clone();
let c = counter.clone();
handles.push(tokio::spawn(async move {
let _permit = q.acquire("session-1").await;
// If serialised, at most 1 task holds the permit at a time.
let prev = c.fetch_add(1, Ordering::SeqCst);
// While we hold the permit, sleep briefly.
sleep(Duration::from_millis(10)).await;
let current = c.load(Ordering::SeqCst);
// Nobody else should have incremented while we held the permit.
assert_eq!(current, prev + 1);
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(counter.load(Ordering::SeqCst), 5);
}
#[tokio::test]
async fn parallel_across_sessions() {
let queue = Arc::new(SessionQueue::new());
let active = Arc::new(AtomicUsize::new(0));
let max_active = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for i in 0..4 {
let q = queue.clone();
let a = active.clone();
let m = max_active.clone();
let session = format!("session-{i}");
handles.push(tokio::spawn(async move {
let _permit = q.acquire(&session).await;
let current = a.fetch_add(1, Ordering::SeqCst) + 1;
m.fetch_max(current, Ordering::SeqCst);
sleep(Duration::from_millis(50)).await;
a.fetch_sub(1, Ordering::SeqCst);
}));
}
for h in handles {
h.await.unwrap();
}
// Multiple sessions should have run concurrently.
assert!(max_active.load(Ordering::SeqCst) > 1);
}
#[tokio::test]
async fn gc_removes_idle_lanes() {
let queue = SessionQueue::new();
{
let _permit = queue.acquire("temp-session").await;
}
// Permit dropped, lane is idle.
queue.gc().await;
assert_eq!(queue.lane_count().await, 0);
}
}
@@ -0,0 +1,783 @@
//! The **sub-agent turn graph** (issue #4249).
//!
//! Per the per-folder `graph.rs` convention, this module owns the sub-agent
//! folder's graph definition, its available tools, and its summarization step —
//! all thin over the shared tinyagents seam
//! ([`run_turn_via_tinyagents_shared`]).
//!
//! **Graph.** A single agent-loop turn driven by the tinyagents harness: the
//! model is called, requested tools run, and the loop repeats until the model
//! returns without further tool calls or the iteration budget is exhausted. The
//! canonical sub-agent turn path (the legacy `run_inner_loop` / `run_turn_engine`
//! are removed); `run_typed_mode` calls it unconditionally.
//!
//! **Available tools.** The sub-agent reuses the parent's harness tools plus the
//! per-spawn dynamic tools, advertised via [`SharedToolAdapter`] over the shared
//! `Arc<Vec<Box<dyn Tool>>>` tool sets (`[dynamic_tools, parent_tools]` — dynamic
//! first so a shadowing dynamic tool executes, matching advertisement), filtered
//! by `allowed_names`. `ask_user_clarification` is the early-exit tool.
//!
//! **Summarization.** When the sub-agent model's effective context window is
//! known, the shared seam installs the context-window summarization step
//! (`tinyagents::summarize`) ahead of the deterministic front-trim — see
//! [`run_subagent_via_graph`], which resolves the window before dispatch.
//!
//! It mirrors the original seams: child progress deltas (`Subagent*` events incl.
//! thinking), mid-flight steering, the `ask_user_clarification` early-exit pause,
//! and a graceful model-call-cap checkpoint summary
//! (`SubagentCheckpoint::on_max_iter`).
use std::collections::HashSet;
use std::sync::Arc;
use super::usage::AggregatedUsage;
use crate::openhuman::agent::harness::subagent_runner::types::SubagentRunError;
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage, Provider};
use crate::openhuman::tinyagents::{run_turn_via_tinyagents_shared, SubagentScope};
use crate::openhuman::tools::{Tool, ToolSpec};
/// Drive a sub-agent turn on the tinyagents harness. Returns
/// `(text, model_calls, AggregatedUsage, early_exit_tool, hit_cap)` — `hit_cap`
/// is `true` when the run stopped at the model-call cap with work still pending
/// (the caller surfaces this as `SubagentRunStatus::Incomplete`, #4096).
#[allow(clippy::too_many_arguments)]
pub(super) async fn run_subagent_via_graph(
provider: Arc<dyn Provider>,
model: &str,
temperature: f64,
history: &mut Vec<ChatMessage>,
parent_tools: Arc<Vec<Box<dyn Tool>>>,
dynamic_tools: Vec<Box<dyn Tool>>,
specs: Vec<ToolSpec>,
allowed_names: HashSet<String>,
max_iterations: usize,
run_queue: Option<Arc<crate::openhuman::agent::harness::run_queue::RunQueue>>,
on_progress: Option<tokio::sync::mpsc::Sender<AgentProgress>>,
agent_id: &str,
task_id: &str,
extended_policy: bool,
worker_thread_id: Option<String>,
workspace_dir: std::path::PathBuf,
max_output_tokens: u32,
model_vision: bool,
) -> Result<(String, usize, AggregatedUsage, Option<String>, bool), SubagentRunError> {
tracing::info!(
model,
max_iterations,
agent_id,
task_id,
model_vision,
observed = on_progress.is_some(),
"[subagent_runner:graph] routing sub-agent turn through tinyagents harness"
);
// `specs` is derived from the registry inside the runner; the tinyagents
// adapters advertise each tool via its own `spec()`, so it's unused here.
let _ = &specs;
// Vision forwarding (parity with the legacy `run_inner_loop`): rehydrate
// `[IMAGE:…]` placeholders in the sub-agent's history when either the
// provider advertises vision or the sub-agent model is user-flagged as
// vision-capable (BYOK/custom). The expanded copy is provider-only — the
// persisted `history` written back below keeps the original markers.
let dispatch_history = if (provider.supports_vision() || model_vision)
&& crate::openhuman::agent::multimodal::has_image_placeholders(history)
{
crate::openhuman::agent::multimodal::rehydrate_image_placeholders(history)
} else {
history.clone()
};
// Child-progress attribution: when the parent carries an `on_progress` sink,
// mirror this sub-agent's iterations / tool calls / text + thinking deltas as
// `Subagent*` events scoped to (`agent_id`, `task_id`) so the parent thread
// can nest them under the live subagent row.
let subagent_scope = on_progress.as_ref().map(|_| SubagentScope {
agent_id: agent_id.to_string(),
task_id: task_id.to_string(),
extended_policy,
});
// Keep a provider handle for the cap-hit summary call (the run consumes the
// other clone).
let summary_provider = provider.clone();
// Resolve the sub-agent model's effective context window so the harness runs
// the context-window summarization step (issue #4249) on sub-agent turns too.
// A long-running / resumed sub-agent (worker threads, durable sessions) can
// accumulate a transcript past its own window; summarize before each model
// call rather than relying solely on the parent's one-time trim.
let context_window = provider.effective_context_window(model).await;
// A sub-agent turn runs *nested inside* the parent agent's turn (parent
// harness → spawn_subagent tool → here), so the child's full
// `run_turn_via_tinyagents_shared` future would otherwise sit on the parent's
// poll stack. Heap-allocate it (as the legacy `run_inner_loop` did) so the
// parent+child harness drives don't overflow the stack.
// Capture native-tool support before `provider` is moved: the durable-history
// append below serializes this turn's typed suffix with the matching dispatcher.
let native_tools = provider.supports_native_tools();
let mut outcome = Box::pin(run_turn_via_tinyagents_shared(
provider,
model,
temperature,
dispatch_history,
// Dynamic (per-spawn) tools first so a dynamic tool that intentionally
// shadows a parent-registry tool of the same name is the one that
// *executes* — matching the advertisement order (`dedup_tool_specs_by_name`
// lists dynamic specs before parent specs in `runner.rs`). The shared
// adapter resolves a name by scanning the sets in order, so a
// parent-first order would run the parent impl for a shadowed name.
vec![Arc::new(dynamic_tools), parent_tools],
allowed_names,
max_iterations,
// Parent's progress sink — child events ride it, scoped below.
on_progress,
subagent_scope,
// Resolved above — drives the sub-agent context-window summarization step.
context_window,
// Mid-flight steering: forward queued steer messages into the run.
run_queue,
// Pause + checkpoint when the child asks the user a clarifying question.
&["ask_user_clarification"],
// Pause gracefully at the model-call cap so we can summarize a resumable
// checkpoint (below) instead of erroring — legacy `on_max_iter` parity.
true,
// Bound the sub-agent's per-call output at its configured budget.
Some(max_output_tokens),
// Context middlewares: cache-align + default tool-result byte cap so a
// sub-agent's (often large) tool outputs stay bounded in its transcript.
crate::openhuman::tinyagents::TurnContextMiddleware::defaults(),
))
.await
.map_err(SubagentRunError::Provider)?;
// Write the final conversation back so the caller can checkpoint / persist.
// Keep the original (un-expanded) prior turns and append only this turn's typed
// suffix, serialized with the matching dispatcher so a native tool round
// persists as the `{content, tool_calls}` / `{tool_call_id, content}` envelope
// (re-parsed by `convert::chat_message_to_message` next turn) instead of an
// assistant with no `tool_calls` followed by an orphan `tool` row. Appending
// the typed `outcome.conversation` (messages-since-last-user) also avoids
// indexing a post-trim `outcome.history` with the pre-trim length, and the
// durable `[IMAGE:…]` markers stay put since the prior user turns are untouched.
use crate::openhuman::agent::dispatcher::ToolDispatcher;
let suffix = if native_tools {
crate::openhuman::agent::dispatcher::NativeToolDispatcher
.to_provider_messages(&outcome.conversation)
} else {
crate::openhuman::agent::dispatcher::XmlToolDispatcher
.to_provider_messages(&outcome.conversation)
};
history.extend(suffix);
let mut usage = AggregatedUsage {
input_tokens: outcome.input_tokens,
output_tokens: outcome.output_tokens,
// Carry the child's cached-prefix tokens + estimated cost (the turn
// outcome now reports both) so sub-agent spend rolls into the parent
// instead of being recorded as uncached and $0.
cached_input_tokens: outcome.cached_input_tokens,
charged_amount_usd: outcome.charged_amount_usd,
};
// Cap hit with work still pending: summarize the run-so-far into a resumable
// checkpoint (the delegating agent continues from partial progress) rather
// than surfacing an empty/partial answer — the legacy `SubagentCheckpoint`.
if outcome.hit_cap {
use super::super::super::engine::CheckpointStrategy;
let digest = build_cap_digest(&outcome.conversation);
let strategy = super::checkpoint::SubagentCheckpoint {
provider: summary_provider.as_ref(),
model: model.to_string(),
temperature,
agent_id: agent_id.to_string(),
// The checkpoint summary call's output cap — the standard per-turn
// budget (the value this field replaced when it was hardcoded).
max_output_tokens: crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS,
};
match strategy.on_max_iter(&digest, max_iterations).await {
Ok(co) => {
if let Some(u) = co.usage {
usage.input_tokens += u.input_tokens;
usage.output_tokens += u.output_tokens;
}
outcome.text = co.text;
}
Err(e) => return Err(SubagentRunError::Provider(e)),
}
}
// Mirror this turn's conversation to the spawn's worker thread (when one is
// attached), matching the legacy `SubagentObserver`: assistant intents +
// final answer as `agent` messages, tool results as `user` messages. The
// initial user prompt was already written when the worker thread was created.
if let Some(thread_id) = worker_thread_id {
mirror_worker_thread(
&workspace_dir,
&thread_id,
agent_id,
task_id,
&outcome.conversation,
// On a cap/early-exit, `outcome.text` is the checkpoint/question that
// replaced (or stands in for) a final assistant turn.
if outcome.hit_cap || outcome.early_exit_tool.is_some() {
Some(outcome.text.as_str())
} else {
None
},
);
}
// On an early-exit (`ask_user_clarification`), `outcome.text` is the question
// and the runner checkpoints + returns AwaitingUser. `None` = ran to a final
// answer (or a cap-hit checkpoint summary).
Ok((
outcome.text,
outcome.model_calls,
usage,
outcome.early_exit_tool,
outcome.hit_cap,
))
}
/// Mirror a sub-agent turn's structured conversation to its worker thread,
/// matching the legacy [`SubagentObserver`]: assistant turns (intents + final)
/// become `agent` messages, tool results become `user` messages. `extra_final`,
/// when set, is appended as a trailing `agent` message (the cap checkpoint or
/// clarifying question, which isn't a plain assistant turn in the transcript).
fn mirror_worker_thread(
workspace_dir: &std::path::Path,
thread_id: &str,
agent_id: &str,
task_id: &str,
conversation: &[ConversationMessage],
extra_final: Option<&str>,
) {
use crate::openhuman::memory_conversations::{
append_message, ConversationMessage as StoredMessage,
};
let mut append = |content: String, sender: &str| {
let message = StoredMessage {
id: format!("{sender}:{}", uuid::Uuid::new_v4()),
content,
message_type: "text".to_string(),
extra_metadata: serde_json::json!({
"scope": "worker_thread",
"agent_id": agent_id,
"task_id": task_id,
}),
sender: sender.to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
};
if let Err(err) = append_message(workspace_dir.to_path_buf(), thread_id, message) {
tracing::debug!(
agent_id,
thread_id,
error = %err,
"[subagent_runner:graph] failed to append worker-thread message"
);
}
};
for msg in conversation {
match msg {
ConversationMessage::AssistantToolCalls { text, .. } => {
if let Some(t) = text.as_deref().filter(|t| !t.trim().is_empty()) {
append(t.to_string(), "agent");
}
}
ConversationMessage::ToolResults(results) => {
for r in results {
append(r.content.clone(), "user");
}
}
ConversationMessage::Chat(c) if c.role == "assistant" => {
if !c.content.trim().is_empty() {
append(c.content.clone(), "agent");
}
}
_ => {}
}
}
if let Some(text) = extra_final.filter(|t| !t.trim().is_empty()) {
append(text.to_string(), "agent");
}
}
/// Build the `tool → outcome` digest the cap-hit summary call summarizes, in the
/// legacy `- {name} [{ok|failed}]: {output}` format (engine `run_tool_digest`),
/// pairing each tool result back to its call by id. Tool success isn't carried
/// on the converted transcript, so results are reported optimistically as `ok`.
fn build_cap_digest(conversation: &[ConversationMessage]) -> String {
use std::collections::HashMap;
use std::fmt::Write as _;
// call_id -> tool name, from this turn's assistant tool-call rounds.
let mut names: HashMap<&str, &str> = HashMap::new();
for msg in conversation {
if let ConversationMessage::AssistantToolCalls { tool_calls, .. } = msg {
for call in tool_calls {
names.insert(call.id.as_str(), call.name.as_str());
}
}
}
let mut out = String::new();
for msg in conversation {
if let ConversationMessage::ToolResults(results) = msg {
for r in results {
let name = names
.get(r.tool_call_id.as_str())
.copied()
.unwrap_or("tool");
let body = crate::openhuman::util::truncate_with_ellipsis(&r.content, 800);
let _ = writeln!(out, "- {name} [ok]: {body}");
}
}
}
out.trim_end().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::inference::provider::{ChatResponse, ToolCall};
use crate::openhuman::tools::ToolResult;
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"echo"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object"})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let m = args.get("msg").and_then(|v| v.as_str()).unwrap_or("");
Ok(ToolResult::success(format!("echoed:{m}")))
}
}
struct TwoStepProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for TwoStepProvider {
async fn chat_with_system(
&self,
_s: Option<&str>,
_m: &str,
_model: &str,
_t: f64,
) -> anyhow::Result<String> {
Ok(String::new())
}
async fn chat(
&self,
_r: crate::openhuman::inference::provider::ChatRequest<'_>,
_model: &str,
_t: f64,
) -> anyhow::Result<ChatResponse> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Ok(ChatResponse {
tool_calls: vec![ToolCall {
id: "1".to_string(),
name: "echo".to_string(),
arguments: r#"{"msg":"hi"}"#.to_string(),
extra_content: None,
}],
..Default::default()
})
} else {
Ok(ChatResponse {
text: Some("all done".to_string()),
..Default::default()
})
}
}
fn supports_native_tools(&self) -> bool {
true
}
}
#[tokio::test]
async fn subagent_runs_through_the_graph_engine_with_real_tools() {
let provider = Arc::new(TwoStepProvider {
calls: AtomicUsize::new(0),
});
let parent_tools: Arc<Vec<Box<dyn Tool>>> = Arc::new(vec![Box::new(EchoTool)]);
let mut allowed = HashSet::new();
allowed.insert("echo".to_string());
let mut history = vec![ChatMessage::user("please echo hi")];
let (output, iterations, usage, early_exit, hit_cap) = run_subagent_via_graph(
provider,
"mock-model",
0.0,
&mut history,
parent_tools,
vec![],
vec![],
allowed,
10,
None,
None,
"researcher",
"task-1",
false,
None,
std::env::temp_dir(),
1024,
false,
)
.await
.expect("graph subagent runs");
assert_eq!(output, "all done");
assert_eq!(iterations, 2);
assert!(early_exit.is_none());
assert!(!hit_cap, "a clean finish should not report a cap hit");
let _ = usage;
// History was written back: user + assistant(tool) + tool result + assistant(final).
assert!(history.len() >= 4);
assert!(history.iter().any(|m| m.content.contains("echoed:hi")));
}
/// A provider that streams visible text + reasoning through the request's
/// delta sender, exercising the child-progress bridge end to end.
struct ThinkingStreamProvider;
#[async_trait]
impl Provider for ThinkingStreamProvider {
async fn chat_with_system(
&self,
_s: Option<&str>,
_m: &str,
_model: &str,
_t: f64,
) -> anyhow::Result<String> {
Ok(String::new())
}
async fn chat(
&self,
r: crate::openhuman::inference::provider::ChatRequest<'_>,
_model: &str,
_t: f64,
) -> anyhow::Result<ChatResponse> {
use crate::openhuman::inference::provider::ProviderDelta;
if let Some(tx) = r.stream {
let _ = tx
.send(ProviderDelta::ThinkingDelta {
delta: "let me think".into(),
})
.await;
for chunk in ["Hel", "lo"] {
let _ = tx
.send(ProviderDelta::TextDelta {
delta: chunk.into(),
})
.await;
}
}
Ok(ChatResponse {
text: Some("Hello".to_string()),
..Default::default()
})
}
fn supports_native_tools(&self) -> bool {
true
}
}
#[tokio::test]
async fn child_text_and_thinking_deltas_are_scoped_to_the_subagent() {
let (tx, mut rx) = tokio::sync::mpsc::channel::<AgentProgress>(64);
let parent_tools: Arc<Vec<Box<dyn Tool>>> = Arc::new(vec![]);
let mut history = vec![ChatMessage::user("hi")];
let (output, _iters, _usage, _early, _hit_cap) = run_subagent_via_graph(
Arc::new(ThinkingStreamProvider),
"mock-model",
0.0,
&mut history,
parent_tools,
vec![],
vec![],
HashSet::new(),
4,
None,
Some(tx),
"researcher",
"task-7",
false,
None,
std::env::temp_dir(),
1024,
false,
)
.await
.expect("child-delta subagent runs");
assert_eq!(output, "Hello");
let mut text = String::new();
let mut thinking = String::new();
let mut saw_iter = false;
while let Ok(p) = rx.try_recv() {
match p {
AgentProgress::SubagentTextDelta { delta, task_id, .. } => {
assert_eq!(task_id, "task-7");
text.push_str(&delta);
}
AgentProgress::SubagentThinkingDelta {
delta, agent_id, ..
} => {
assert_eq!(agent_id, "researcher");
thinking.push_str(&delta);
}
AgentProgress::SubagentIterationStarted { task_id, .. } => {
assert_eq!(task_id, "task-7");
saw_iter = true;
}
// The parent-scoped variants must never appear on a child run.
AgentProgress::TextDelta { .. }
| AgentProgress::ThinkingDelta { .. }
| AgentProgress::IterationStarted { .. } => {
panic!("child run emitted a parent-scoped progress event");
}
_ => {}
}
}
assert!(saw_iter, "a SubagentIterationStarted should be emitted");
assert!(
text.contains("Hello"),
"child text deltas should reassemble, got {text:?}"
);
assert!(
thinking.contains("let me think"),
"child thinking deltas should be forwarded, got {thinking:?}"
);
}
/// A tool named like the early-exit tool that echoes its `question` arg.
struct AskTool;
#[async_trait]
impl Tool for AskTool {
fn name(&self) -> &str {
"ask_user_clarification"
}
fn description(&self) -> &str {
"ask the user a clarifying question"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {"question": {"type": "string"}}})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let q = args
.get("question")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(ToolResult::success(q))
}
}
/// A provider whose first turn calls `ask_user_clarification`; a second turn
/// would answer, but the early-exit pause should stop the loop before it.
struct AskThenAnswer {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for AskThenAnswer {
async fn chat_with_system(
&self,
_s: Option<&str>,
_m: &str,
_model: &str,
_t: f64,
) -> anyhow::Result<String> {
Ok(String::new())
}
async fn chat(
&self,
_r: crate::openhuman::inference::provider::ChatRequest<'_>,
_model: &str,
_t: f64,
) -> anyhow::Result<ChatResponse> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Ok(ChatResponse {
tool_calls: vec![ToolCall {
id: "ask-1".to_string(),
name: "ask_user_clarification".to_string(),
arguments: r#"{"question":"which file?"}"#.to_string(),
extra_content: None,
}],
..Default::default()
})
} else {
Ok(ChatResponse {
text: Some("should not be reached".to_string()),
..Default::default()
})
}
}
fn supports_native_tools(&self) -> bool {
true
}
}
#[tokio::test]
async fn ask_user_clarification_pauses_and_surfaces_the_question() {
let provider = Arc::new(AskThenAnswer {
calls: AtomicUsize::new(0),
});
let parent_tools: Arc<Vec<Box<dyn Tool>>> = Arc::new(vec![Box::new(AskTool)]);
let mut allowed = HashSet::new();
allowed.insert("ask_user_clarification".to_string());
let mut history = vec![ChatMessage::user("help me")];
let (output, iterations, _usage, early_exit, _hit_cap) = run_subagent_via_graph(
provider.clone(),
"mock-model",
0.0,
&mut history,
parent_tools,
vec![],
vec![],
allowed,
10,
None,
None,
"researcher",
"task-9",
false,
None,
std::env::temp_dir(),
1024,
false,
)
.await
.expect("ask-clarification subagent runs");
// The loop paused after the tool round: the early-exit tool is surfaced
// and the question is the returned text — the second model turn never ran.
assert_eq!(early_exit.as_deref(), Some("ask_user_clarification"));
assert_eq!(output, "which file?");
assert_eq!(
iterations, 1,
"the loop should pause before a second model call"
);
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
}
/// A tool that always succeeds, so the loop keeps going until the cap.
struct NoopTool;
#[async_trait]
impl Tool for NoopTool {
fn name(&self) -> &str {
"noop"
}
fn description(&self) -> &str {
"no-op"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object"})
}
async fn execute(&self, _a: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult::success("ok"))
}
}
/// A provider that never finishes: every tool-enabled turn asks for `noop`.
/// A request with no tools is the cap-hit summary call — it returns prose.
struct LoopForeverProvider;
#[async_trait]
impl Provider for LoopForeverProvider {
async fn chat_with_system(
&self,
_s: Option<&str>,
_m: &str,
_model: &str,
_t: f64,
) -> anyhow::Result<String> {
Ok(String::new())
}
async fn chat(
&self,
r: crate::openhuman::inference::provider::ChatRequest<'_>,
_model: &str,
_t: f64,
) -> anyhow::Result<ChatResponse> {
if r.tools.is_some() {
Ok(ChatResponse {
tool_calls: vec![ToolCall {
id: "n".to_string(),
name: "noop".to_string(),
arguments: "{}".to_string(),
extra_content: None,
}],
..Default::default()
})
} else {
// The summary call (tools=None): return a progress checkpoint.
Ok(ChatResponse {
text: Some("progress: explored two leads".to_string()),
..Default::default()
})
}
}
fn supports_native_tools(&self) -> bool {
true
}
}
#[tokio::test]
async fn cap_hit_summarizes_a_resumable_checkpoint() {
let parent_tools: Arc<Vec<Box<dyn Tool>>> = Arc::new(vec![Box::new(NoopTool)]);
let mut allowed = HashSet::new();
allowed.insert("noop".to_string());
let mut history = vec![ChatMessage::user("do a big task")];
let (output, iterations, _usage, early_exit, hit_cap) = run_subagent_via_graph(
Arc::new(LoopForeverProvider),
"mock-model",
0.0,
&mut history,
parent_tools,
vec![],
vec![],
allowed,
2,
None,
None,
"researcher",
"task-cap",
false,
None,
std::env::temp_dir(),
1024,
false,
)
.await
.expect("cap-hit subagent runs");
// The loop paused at the 2-call budget and summarized instead of erroring.
assert!(early_exit.is_none());
assert!(hit_cap, "reaching the model-call cap should report hit_cap");
assert_eq!(iterations, 2, "the loop should stop at the model-call cap");
assert!(
output.contains("progress: explored two leads"),
"cap hit should return the summary checkpoint, got {output:?}"
);
}
}
@@ -1,274 +0,0 @@
//! Sub-agent inner tool-call loop.
//!
//! Drives the iterative cycle of provider calls and tool execution until the
//! model returns without further tool calls (or the iteration budget is
//! exhausted). Unlike the main agent loop, this is isolated and returns only
//! the final text to be synthesised by the parent.
use std::collections::HashSet;
use crate::openhuman::agent::harness::engine::TurnStop;
use crate::openhuman::agent::harness::fork_context::ParentExecutionContext;
use crate::openhuman::agent::harness::subagent_runner::handoff::ResultHandoffCache;
use crate::openhuman::agent::harness::subagent_runner::types::SubagentRunError;
use crate::openhuman::inference::provider::Provider;
use crate::openhuman::tools::{Tool, ToolSpec};
use super::super::tool_prep::build_text_mode_tool_instructions;
use super::checkpoint::SubagentCheckpoint;
use super::observer::SubagentObserver;
use super::provider::LazyToolkitResolver;
use super::tool_source::SubagentToolSource;
/// Cumulative usage stats gathered across all provider calls in the loop.
#[derive(Debug, Clone, Default)]
pub(super) struct AggregatedUsage {
pub(super) input_tokens: u64,
pub(super) output_tokens: u64,
pub(super) cached_input_tokens: u64,
pub(super) charged_amount_usd: f64,
}
/// The sub-agent's private tool-execution engine.
///
/// This function drives the iterative cycle of:
/// 1. Sending messages to the provider.
/// 2. Parsing the provider's response for tool calls.
/// 3. Executing tools (with sandboxing and timeouts).
/// 4. Appending results to history and looping until a final response is found.
///
/// Unlike the main agent loop, this is isolated and returns only the final text
/// to be synthesized by the parent.
#[allow(clippy::too_many_arguments)]
pub(super) async fn run_inner_loop(
provider: &dyn Provider,
history: &mut Vec<crate::openhuman::inference::provider::ChatMessage>,
parent_tools: &[Box<dyn Tool>],
extra_tools: Vec<Box<dyn Tool>>,
tool_specs: &[ToolSpec],
allowed_names: HashSet<String>,
lazy_resolver: Option<LazyToolkitResolver>,
model: &str,
// User-configured vision flag for `model` (computed at the call site), set as
// the `current_model_vision` task-local around the engine call so a flagged
// custom/BYOK sub-agent model can forward images.
model_vision: bool,
temperature: f64,
max_iterations: usize,
max_output_tokens: u32,
task_id: &str,
agent_id: &str,
worker_thread_id: Option<String>,
handoff_cache: Option<&ResultHandoffCache>,
parent: &ParentExecutionContext,
extended_policy: bool,
tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression,
// Optional steering channel. When `Some`, the child engine drains
// steer/collect messages at iteration boundaries so the parent can
// `steer_subagent` a running async sub-agent. `None` = non-steerable.
run_queue: Option<std::sync::Arc<crate::openhuman::agent::harness::run_queue::RunQueue>>,
) -> Result<(String, usize, AggregatedUsage, Option<String>, TurnStop), SubagentRunError> {
// An autonomous skill run (set via `with_autonomous_iter_cap`) lifts the
// per-agent cap so sub-agents run until done / the circuit breaker trips.
let max_iterations = super::super::autonomous::autonomous_iter_cap()
.map(|cap| cap.max(max_iterations))
.unwrap_or(max_iterations)
.max(1);
// Sub-agent transcript stem — computed once up front so every iteration's
// persist resolves to the same file: `{parent_chain}__{unix_ts}_{agent_id}`.
let child_session_key = {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let unix_ts = now.as_secs();
let nanos = now.subsec_nanos();
let sanitized: String = agent_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
let task_suffix: String = task_id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.take(12)
.collect();
if task_suffix.is_empty() {
format!("{unix_ts}_{nanos:09}_{sanitized}")
} else {
format!("{unix_ts}_{nanos:09}_{sanitized}_{task_suffix}")
}
};
let transcript_stem = {
let parent_chain = match parent.session_parent_prefix.as_deref() {
Some(prefix) => format!("{}__{}", prefix, parent.session_key),
None => parent.session_key.clone(),
};
format!("{parent_chain}__{child_session_key}")
};
// ── Text-mode override for integrations_agent ──
// Large Composio toolkits compile into provider grammars that blow the
// 65 535-rule ceiling, so for `integrations_agent` we omit `tools: [...]`
// and describe them in the system prompt as prose, parsing `<tool_call>`
// tags out of the model's response. Forcing `request_specs() == &[]` makes
// the engine skip native tools and fall back to its XML parse + batched
// `[Tool results]` path — exactly what text mode needs.
let force_text_mode = agent_id == "integrations_agent" && !tool_specs.is_empty();
if force_text_mode {
if let Some(sys) = history.iter_mut().find(|m| m.role == "system") {
sys.content.push_str("\n\n");
sys.content
.push_str(&build_text_mode_tool_instructions(tool_specs));
}
tracing::info!(
task_id = %task_id,
agent_id = %agent_id,
tool_count = tool_specs.len(),
"[subagent_runner:text-mode] omitting tools from API request, injected XML tool protocol into system prompt"
);
}
let advertised_specs: Vec<ToolSpec> = if force_text_mode {
Vec::new()
} else {
tool_specs.to_vec()
};
let mut tool_source = SubagentToolSource {
parent_tools,
extra_tools,
allowed_names,
lazy_resolver,
advertised_specs,
handoff_cache,
policy: crate::openhuman::tools::policy::DefaultToolPolicy,
agent_id: agent_id.to_string(),
tokenjuice_compression,
};
let mut observer = SubagentObserver {
worker_thread_id,
workspace_dir: parent.workspace_dir.clone(),
transcript_stem,
agent_id: agent_id.to_string(),
task_id: task_id.to_string(),
force_text_mode,
usage: AggregatedUsage::default(),
last_turn_usage: None,
};
let checkpoint = SubagentCheckpoint {
provider,
model: model.to_string(),
temperature,
agent_id: agent_id.to_string(),
max_output_tokens,
};
let progress = super::super::super::engine::SubagentProgress {
sink: parent.on_progress.clone(),
agent_id: agent_id.to_string(),
task_id: task_id.to_string(),
extended_policy,
};
let parser = super::super::super::engine::DefaultParser;
// Sub-agents have no typed `ContextManager`, so opt the shared turn engine
// into LLM autocompaction: when the context guard reports the window is
// filling, the engine summarizes the flat `ChatMessage` history in place
// (protecting the leading system prompt + recent tail) instead of only
// hard-trimming the oldest messages. Gated on the same `context` config the
// main chat uses, so disabling autocompaction disables it everywhere.
let autocompact = subagent_autocompact_config().await;
// Heap-allocate the child `run_turn_engine` state machine. Sub-agents
// run as nested polls inside the *parent* agent's `run_turn_engine`
// (the orchestrator → tool exec → `dispatch_subagent` → `run_subagent`
// chain), so without the box the parent's tokio worker poll stack
// also has to carry the child engine's ~600-line generator. That
// crosses the 2 MiB tokio worker default and aborts with
// "thread 'tokio-rt-worker' has overflowed its stack" — see the
// `chat-harness-subagent` Playwright lane crash logged here:
// `[subagent_runner] dispatching agent_id=researcher ... → fatal
// runtime error: stack overflow`. Boxing here breaks the stack
// accumulation at the recursion boundary. Smoke-tested in
// `nested_subagent_dispatch_runs_on_a_constrained_worker_stack`;
// the deep end-to-end catcher is the `chat-harness-subagent`
// Playwright spec.
let outcome = crate::openhuman::tokenjuice::savings::with_turn_model(
model.to_string(),
// Box the context chain so the added `with_turn_model` scope keeps the
// nested sub-agent future off the constrained worker stack (mirrors the
// existing `Box::pin` guard below; issue #4122 review).
Box::pin(
super::super::super::model_vision_context::with_current_model_vision(
model_vision,
Box::pin(super::super::super::engine::run_turn_engine(
provider,
history,
&mut tool_source,
&progress,
&mut observer,
&checkpoint,
&parser,
"subagent",
model,
temperature,
true, // silent — sub-agents never echo to stdout
&crate::openhuman::config::MultimodalConfig::default(),
&crate::openhuman::config::MultimodalFileConfig::default(),
max_iterations,
max_output_tokens,
None, // sub-agents don't stream a draft
&["ask_user_clarification"],
run_queue, // steering channel for `steer_subagent` (None = non-steerable)
autocompact.as_ref(),
)),
),
),
)
.await?;
Ok((
outcome.text,
outcome.iterations as usize,
observer.usage,
outcome.early_exit_tool,
outcome.stop,
))
}
/// Build the sub-agent's engine-autocompaction config from the global
/// `context` settings, or `None` when context management / autocompaction is
/// disabled (in which case the engine falls back to hard token-budget trim).
///
/// Reuses the same `enabled` + `autocompact_enabled` toggles and
/// `summarizer_model` override as the main chat, so the two paths stay in sync.
async fn subagent_autocompact_config() -> Option<crate::openhuman::context::EngineAutocompact> {
let config = match crate::openhuman::config::Config::load_or_init().await {
Ok(cfg) => cfg,
Err(err) => {
tracing::warn!(
error = %err,
"[subagent_runner] failed to load config; engine autocompaction disabled"
);
return None;
}
};
let ctx = &config.context;
if !ctx.enabled || !ctx.autocompact_enabled {
tracing::debug!(
enabled = ctx.enabled,
autocompact_enabled = ctx.autocompact_enabled,
"[subagent_runner] engine autocompaction disabled by context config"
);
return None;
}
Some(crate::openhuman::context::EngineAutocompact::with_defaults(
ctx.summarizer_model.clone(),
))
}
@@ -1,10 +1,11 @@
//! Sub-agent execution entry points and the inner tool-call loop.
//! Sub-agent execution entry points.
//!
//! The public runner lives in [`run_subagent`]. It dispatches to
//! [`runner::run_typed_mode`] (narrow prompt + filtered tools) which builds a
//! brand-new system prompt and a filtered tool list for the requested
//! archetype, then drives provider calls and tool execution until the model
//! returns without further tool calls (or the iteration budget is exhausted).
//! archetype, then drives the turn through the tinyagents harness
//! ([`graph::run_subagent_via_graph`]) until the model returns without
//! further tool calls (or the iteration budget is exhausted).
//!
//! ## Layout
//!
@@ -13,20 +14,18 @@
//! | `provider.rs` | `resolve_subagent_provider`, `user_is_signed_in_to_composio`, `LazyToolkitResolver` |
//! | `prompt.rs` | Role-contract suffix, `append_subagent_role_contract`, `dedup_tool_specs_by_name` |
//! | `runner.rs` | `run_subagent`, `run_typed_mode` |
//! | `loop_.rs` | `run_inner_loop`, `AggregatedUsage` |
//! | `tool_source.rs` | `SubagentToolSource` |
//! | `graph.rs` | `run_subagent_via_graph` — the sub-agent turn graph + tools |
//! | `usage.rs` | `AggregatedUsage` (cumulative usage stats) |
//! | `handoff_helper.rs` | `apply_handoff` |
//! | `observer.rs` | `SubagentObserver` |
//! | `checkpoint.rs` | `SubagentCheckpoint`, `parse_tool_arguments` |
mod checkpoint;
mod graph;
mod handoff_helper;
mod loop_;
mod observer;
mod prompt;
mod provider;
mod runner;
mod tool_source;
mod usage;
// Public entry point — the primary API surface consumed by the parent module.
pub use runner::run_subagent;
@@ -1,236 +0,0 @@
//! Sub-agent [`TurnObserver`] implementation.
//!
//! Accumulates usage stats, persists per-iteration transcripts, and
//! mirrors assistant intents / tool results / final responses to the
//! spawn's worker thread (when one is attached).
use crate::openhuman::inference::provider::ChatMessage;
use crate::openhuman::memory_conversations::ConversationMessage;
use super::super::super::session::transcript;
use super::loop_::AggregatedUsage;
pub(super) struct SubagentObserver {
pub(super) worker_thread_id: Option<String>,
pub(super) workspace_dir: std::path::PathBuf,
pub(super) transcript_stem: String,
pub(super) agent_id: String,
pub(super) task_id: String,
pub(super) force_text_mode: bool,
pub(super) usage: AggregatedUsage,
/// Provenance + usage of the sub-agent's most recent provider call. Persisted
/// onto the transcript's last assistant message (carries provider + model so
/// per-thread usage reads can price the sub-agent at its actual model).
pub(super) last_turn_usage: Option<transcript::TurnUsage>,
}
impl SubagentObserver {
pub(super) fn append_worker_message(
&self,
content: String,
sender: String,
extra_metadata: serde_json::Value,
) {
let Some(ref thread_id) = self.worker_thread_id else {
return;
};
let message = ConversationMessage {
id: format!("{}:{}", sender, uuid::Uuid::new_v4()),
content,
message_type: "text".to_string(),
extra_metadata,
sender,
created_at: chrono::Utc::now().to_rfc3339(),
};
if let Err(err) = crate::openhuman::memory_conversations::append_message(
self.workspace_dir.clone(),
thread_id,
message,
) {
tracing::debug!(
agent_id = %self.agent_id,
thread_id = %thread_id,
error = %err,
"[subagent_runner] failed to append message to worker thread"
);
}
}
pub(super) fn persist_transcript(&self, history: &[ChatMessage]) {
let path = match transcript::resolve_keyed_transcript_path(
&self.workspace_dir,
&self.transcript_stem,
) {
Ok(p) => p,
Err(err) => {
tracing::debug!(
agent_id = %self.agent_id,
error = %err,
"[subagent_runner] failed to resolve transcript path"
);
return;
}
};
let now = chrono::Utc::now().to_rfc3339();
let meta = transcript::TranscriptMeta {
agent_name: self.agent_id.clone(),
agent_id: Some(self.agent_id.clone()),
agent_type: Some("subagent".to_string()),
dispatcher: "native".into(),
provider: self
.last_turn_usage
.as_ref()
.map(|usage| usage.provider.clone()),
model: self
.last_turn_usage
.as_ref()
.map(|usage| usage.model.clone()),
created: now.clone(),
updated: now,
turn_count: 1,
input_tokens: self.usage.input_tokens,
output_tokens: self.usage.output_tokens,
cached_input_tokens: self.usage.cached_input_tokens,
charged_amount_usd: self.usage.charged_amount_usd,
thread_id: crate::openhuman::inference::provider::thread_context::current_thread_id(),
task_id: Some(self.task_id.clone()),
};
if let Err(err) =
transcript::write_transcript(&path, history, &meta, self.last_turn_usage.as_ref())
{
tracing::debug!(
agent_id = %self.agent_id,
error = %err,
"[subagent_runner] failed to write transcript"
);
}
}
}
#[async_trait::async_trait]
impl super::super::super::engine::TurnObserver for SubagentObserver {
fn record_usage(
&mut self,
provider: &str,
model: &str,
usage: &crate::openhuman::inference::provider::UsageInfo,
) {
self.usage.input_tokens += usage.input_tokens;
self.usage.output_tokens += usage.output_tokens;
self.usage.cached_input_tokens += usage.cached_input_tokens;
// Effective per-call cost: backend-charged when present, else the
// per-model catalog estimate (#4124) — so a sub-agent on a BYO/local
// provider that bills no charge still contributes a priced cost to the
// parent turn's net total. `charged_amount_usd` here is the *net cost*,
// matching the parent adapter's convention.
let call_cost = crate::openhuman::agent::cost::call_cost_usd(model, usage);
self.usage.charged_amount_usd += call_cost;
// Mirror the parent adapter: feed every sub-agent provider call into the
// global cost tracker so the cost dashboard/summary reflects delegated
// spend (previously sub-agent tokens were invisible to it). The per-turn
// rollup into the UI footer happens separately via `turn_subagent_usage`.
crate::openhuman::cost::record_provider_usage(model, usage);
// Provenance for the transcript's last assistant message (#4134): keeps
// the provider + model so per-thread usage reads can price the sub-agent
// at its actual model.
self.last_turn_usage = Some(transcript::TurnUsage {
provider: provider.to_string(),
model: model.to_string(),
usage: transcript::MessageUsage {
input: usage.input_tokens,
output: usage.output_tokens,
cached_input: usage.cached_input_tokens,
context_window: usage.context_window,
cost_usd: call_cost,
},
ts: chrono::Utc::now().to_rfc3339(),
reasoning_content: None,
tool_calls: Vec::new(),
iteration: 0,
});
}
async fn on_assistant(
&mut self,
_display_text: &str,
response_text: &str,
reasoning_content: Option<&str>,
native_tool_calls: &[crate::openhuman::inference::provider::ToolCall],
parsed_calls: &[super::super::super::parse::ParsedToolCall],
iteration: usize,
is_final: bool,
) {
let tool_calls = parsed_calls.len();
if let Some(ref mut usage) = self.last_turn_usage {
usage.reasoning_content = reasoning_content
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string);
usage.tool_calls = native_tool_calls.to_vec();
usage.iteration = (iteration + 1) as u32;
}
let extra = if is_final {
serde_json::json!({
"scope": "worker_thread",
"agent_id": self.agent_id,
"task_id": self.task_id,
"iteration": iteration + 1,
"final": true,
})
} else {
serde_json::json!({
"scope": "worker_thread",
"agent_id": self.agent_id,
"task_id": self.task_id,
"iteration": iteration + 1,
"tool_calls": tool_calls,
})
};
self.append_worker_message(response_text.to_string(), "agent".to_string(), extra);
}
fn on_tool_result(
&mut self,
call_id: &str,
tool_name: &str,
result_text: &str,
_success: bool,
iteration: usize,
) {
// Native mode mirrors each tool result individually; text mode batches
// them in `on_results_batch` instead.
if self.force_text_mode {
return;
}
self.append_worker_message(
result_text.to_string(),
"user".to_string(),
serde_json::json!({
"scope": "worker_thread",
"agent_id": self.agent_id,
"task_id": self.task_id,
"iteration": iteration + 1,
"tool_call_id": call_id,
"tool_name": tool_name,
}),
);
}
fn on_results_batch(&mut self, content: &str, iteration: usize) {
self.append_worker_message(
content.to_string(),
"user".to_string(),
serde_json::json!({
"scope": "worker_thread",
"agent_id": self.agent_id,
"task_id": self.task_id,
"iteration": iteration + 1,
"mode": "text",
}),
);
}
fn after_iteration(&mut self, history: &[ChatMessage], _iteration: usize) {
self.persist_transcript(history);
}
}
@@ -33,9 +33,6 @@ use crate::openhuman::file_state::with_file_state_agent_id;
use crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS;
use crate::openhuman::tools::{Tool, ToolCategory, ToolSpec};
use crate::openhuman::agent::harness::engine::TurnStop;
use super::loop_::run_inner_loop;
use super::prompt::{append_subagent_role_contract, dedup_tool_specs_by_name};
use super::provider::{
resolve_subagent_provider, user_is_signed_in_to_composio, LazyToolkitResolver,
@@ -800,29 +797,92 @@ async fn run_typed_mode(
model_vision,
"[subagent_runner] resolved sub-agent model vision capability"
);
let (output, iterations, agg_usage, early_exit_tool, stop) = Box::pin(run_inner_loop(
subagent_provider.as_ref(),
&mut history,
&parent.all_tools,
dynamic_tools,
&filtered_specs,
allowed_names,
lazy_resolver,
&model,
model_vision,
temperature,
definition.effective_max_iterations(),
max_output_tokens,
task_id,
&definition.id,
options.worker_thread_id.clone(),
handoff_cache.as_deref(),
parent,
definition.iteration_policy == IterationPolicy::Extended,
definition.effective_tokenjuice_compression(),
options.run_queue.clone(),
))
.await?;
// Sub-agent turns run through the tinyagents harness (issue #4249): the graph
// route reuses the same provider + tools and mirrors every legacy seam (child
// progress, steering, cap checkpoint, ask_user_clarification pause,
// worker-thread mirror). The legacy `run_inner_loop` has been removed.
//
// `model_vision` and `max_output_tokens` are now forwarded into the graph
// route (image rehydration + per-call output cap). `lazy_resolver` /
// `handoff_cache` — the integrations-agent progressive-disclosure seams — are
// not yet re-expressed on the tinyagents path; they need a tool-result
// interception middleware and are tracked as a follow-up (issue #4249, 1b).
let _ = (&lazy_resolver, &handoff_cache);
// Per-agent turn graph (issue #4249): `Default` runs the shared sub-agent
// graph; `Custom` hands the assembled turn to this agent's own graph runner
// (declared in its `graph.rs::graph()`). Every built-in agent selects
// `Default` today — the branch is the extension point.
use super::usage::AggregatedUsage;
use crate::openhuman::agent::harness::agent_graph::{
AgentGraph, AgentTurnRequest, AgentTurnUsage,
};
let (output, iterations, agg_usage, early_exit_tool, hit_cap) = match &definition.graph {
AgentGraph::Default => {
super::graph::run_subagent_via_graph(
subagent_provider.clone(),
&model,
temperature,
&mut history,
parent.all_tools.clone(),
dynamic_tools,
filtered_specs.clone(),
allowed_names,
definition.effective_max_iterations(),
options.run_queue.clone(),
parent.on_progress.clone(),
&definition.id,
task_id,
definition.iteration_policy == IterationPolicy::Extended,
options.worker_thread_id.clone(),
parent.workspace_dir.clone(),
max_output_tokens,
model_vision,
)
.await?
}
AgentGraph::Custom(run) => {
let req = AgentTurnRequest {
provider: subagent_provider.clone(),
model: model.clone(),
temperature,
history: std::mem::take(&mut history),
parent_tools: parent.all_tools.clone(),
dynamic_tools,
specs: filtered_specs.clone(),
allowed_names,
max_iterations: definition.effective_max_iterations(),
run_queue: options.run_queue.clone(),
on_progress: parent.on_progress.clone(),
agent_id: definition.id.clone(),
task_id: task_id.to_string(),
extended_policy: definition.iteration_policy == IterationPolicy::Extended,
worker_thread_id: options.worker_thread_id.clone(),
workspace_dir: parent.workspace_dir.clone(),
max_output_tokens,
model_vision,
};
let res = run(req).await?;
history = res.history;
let AgentTurnUsage {
input_tokens,
output_tokens,
cached_input_tokens,
charged_amount_usd,
} = res.usage;
(
res.output,
res.iterations,
AggregatedUsage {
input_tokens,
output_tokens,
cached_input_tokens,
charged_amount_usd,
},
res.early_exit_tool,
res.hit_cap,
)
}
};
// Determine status: if the turn engine exited early because of
// ask_user_clarification, checkpoint the history and return
@@ -888,27 +948,20 @@ async fn run_typed_mode(
question,
options: options_vec,
}
} else {
use crate::openhuman::agent::harness::subagent_runner::types::SubagentRunStatus;
match stop {
// A circuit-breaker halt (stuck) or the iteration cap means the
// sub-agent stopped WITHOUT reaching its goal. Surface it as
// Incomplete so the delegating agent relays the partial result +
// blocker instead of treating the summary as a finished answer or
// re-spinning the identical delegation (#4096).
TurnStop::Halted => SubagentRunStatus::Incomplete {
reason:
"got stuck and stopped making progress (a no-progress circuit breaker tripped)"
.into(),
},
TurnStop::Cap => SubagentRunStatus::Incomplete {
reason: "reached its tool-call limit before finishing".into(),
},
// A clean final response. (An `ask_user_clarification` early-exit is
// already handled by the branch above, so EarlyExit here — which
// sub-agents only reach via that tool — folds into Completed.)
TurnStop::Final | TurnStop::EarlyExit => SubagentRunStatus::Completed,
} else if hit_cap {
// The tinyagents run stopped at the model-call cap with work still
// pending (graph summarized a resumable checkpoint into `output`).
// Surface it as Incomplete so the delegating agent relays the partial
// result + blocker instead of treating the summary as a finished answer
// or re-spinning the identical delegation (#4096).
crate::openhuman::agent::harness::subagent_runner::types::SubagentRunStatus::Incomplete {
reason: "reached its tool-call limit before finishing".into(),
}
} else {
// A clean final response. (An `ask_user_clarification` early-exit is
// handled by the branch above.) The legacy circuit-breaker `Halted`
// distinction folds into the tinyagents stop-hook / cap handling.
crate::openhuman::agent::harness::subagent_runner::types::SubagentRunStatus::Completed
};
// Surface this run's token/cost totals so the parent turn can roll them
@@ -1,135 +0,0 @@
//! Sub-agent [`ToolSource`] implementation.
//!
//! Looks up tools in `extra_tools` then the parent registry, lazily registers
//! toolkit actions the fuzzy filter omitted, rejects names outside the
//! allowlist, and routes execution through the shared [`run_one_tool`] (so
//! sub-agents now get the same approval gate, audit, credential scrub,
//! tokenjuice and timeout as the channel loop), then applies the
//! progressive-disclosure handoff.
use std::collections::HashSet;
use crate::openhuman::tools::{Tool, ToolSpec};
use super::handoff_helper::apply_handoff;
use super::provider::LazyToolkitResolver;
use crate::openhuman::agent::harness::subagent_runner::handoff::ResultHandoffCache;
/// Sub-agent [`ToolSource`]: looks up tools in `extra_tools` then the parent
/// registry, lazily registers toolkit actions the fuzzy filter omitted, rejects
/// names outside the allowlist, and routes execution through the shared
/// [`run_one_tool`] (so sub-agents now get the same approval gate, audit,
/// credential scrub, tokenjuice and timeout as the channel loop), then applies
/// the progressive-disclosure handoff.
pub(super) struct SubagentToolSource<'a> {
pub(super) parent_tools: &'a [Box<dyn Tool>],
pub(super) extra_tools: Vec<Box<dyn Tool>>,
pub(super) allowed_names: HashSet<String>,
pub(super) lazy_resolver: Option<LazyToolkitResolver>,
pub(super) advertised_specs: Vec<ToolSpec>,
pub(super) handoff_cache: Option<&'a ResultHandoffCache>,
pub(super) policy: crate::openhuman::tools::policy::DefaultToolPolicy,
pub(super) agent_id: String,
pub(super) tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression,
}
#[async_trait::async_trait]
impl super::super::super::engine::ToolSource for SubagentToolSource<'_> {
fn request_specs(&self) -> &[ToolSpec] {
&self.advertised_specs
}
async fn execute_call(
&mut self,
call: &super::super::super::parse::ParsedToolCall,
iteration: usize,
progress: &dyn super::super::super::engine::ProgressReporter,
progress_call_id: &str,
) -> super::super::super::engine::ToolRunResult {
// Lazy registration: a call for an unknown tool that matches a real
// action slug in the bound toolkit gets built on the spot and admitted
// to the allowlist. The fuzzy top-K filter keeps schemas out of the
// prompt, not out of execution.
if !self.allowed_names.contains(&call.name) {
if let Some(resolver) = self.lazy_resolver.as_ref() {
if let Some(tool) = resolver.resolve(&call.name) {
tracing::info!(
agent_id = %self.agent_id,
tool = %call.name,
"[subagent_runner] lazily registered toolkit action outside fuzzy top-K"
);
self.allowed_names.insert(tool.name().to_string());
self.extra_tools.push(tool);
}
}
}
if !self.allowed_names.contains(&call.name) {
tracing::warn!(
agent_id = %self.agent_id,
tool = %call.name,
"[subagent_runner] tool not in allowlist for this sub-agent"
);
let iteration_u32 = (iteration + 1) as u32;
// Not-in-allowlist reject path: no resolved tool to label, so defer
// to the client formatter. (Allowed sub-agent calls run through the
// shared `run_one_tool`, which computes the label there.)
progress
.tool_started(
progress_call_id,
&call.name,
&call.arguments,
iteration_u32,
None,
None,
)
.await;
let mut available: Vec<&str> = self.allowed_names.iter().map(|s| s.as_str()).collect();
if let Some(resolver) = self.lazy_resolver.as_ref() {
available.extend(resolver.known_slugs());
}
available.sort_unstable();
available.dedup();
let text = format!(
"Error: tool '{}' is not available to the {} sub-agent. Available tools: {}",
call.name,
self.agent_id,
available.join(", ")
);
progress
.tool_completed(progress_call_id, &call.name, false, &text, 0, iteration_u32)
.await;
return super::super::super::engine::ToolRunResult {
text,
success: false,
};
}
let tool_opt: Option<&dyn Tool> = self
.extra_tools
.iter()
.find(|t| t.name() == call.name)
.or_else(|| self.parent_tools.iter().find(|t| t.name() == call.name))
.map(|b| b.as_ref());
let outcome = super::super::super::engine::run_one_tool(
tool_opt,
call,
iteration,
progress,
&self.policy,
None,
progress_call_id,
self.tokenjuice_compression,
)
.await;
let text = match self.handoff_cache {
Some(cache) => apply_handoff(cache, &call.name, "", &self.agent_id, outcome.text),
None => outcome.text,
};
super::super::super::engine::ToolRunResult {
text,
success: outcome.success,
}
}
}
@@ -0,0 +1,14 @@
//! Cumulative sub-agent usage stats.
//!
//! The sub-agent inner tool-call loop that produced these was retired in favour
//! of the tinyagents harness (issue #4249); only the usage aggregate it returned
//! survives, reused by the tinyagents sub-agent route ([`super::graph`]).
/// Cumulative usage stats gathered across a sub-agent run.
#[derive(Debug, Clone, Default)]
pub(super) struct AggregatedUsage {
pub(super) input_tokens: u64,
pub(super) output_tokens: u64,
pub(super) cached_input_tokens: u64,
pub(super) charged_amount_usd: f64,
}
@@ -70,6 +70,7 @@ fn make_def_named_tools(names: &[&str]) -> AgentDefinition {
delegate_name: None,
agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker,
source: crate::openhuman::agent::harness::definition::DefinitionSource::Builtin,
graph: Default::default(),
}
}
@@ -515,37 +516,43 @@ async fn typed_mode_returns_text_through_runner() {
}
#[tokio::test]
async fn stuck_subagent_returns_incomplete_status() {
async fn capped_no_progress_subagent_returns_incomplete_status() {
use crate::openhuman::agent::harness::subagent_runner::SubagentRunStatus;
// A sub-agent that re-issues the identical (succeeding) tool call makes no
// progress. The repeat-call breaker halts it, and the runner reports
// `Incomplete` (NOT `Completed`) so the orchestrator gets a structured
// A sub-agent that keeps issuing tool calls without ever producing a final
// answer makes no progress and is halted at its model-call cap. The runner
// summarizes the run-so-far into a resumable checkpoint and reports
// `Incomplete` (NOT `Completed`) so the orchestrator relays the partial
// handback instead of mistaking the no-progress summary for a result (#4096).
//
// The legacy repeat-identical-call circuit-breaker `Halted` distinction
// folded into this cap handling during the tinyagents migration (#4249) —
// see `run_subagent`'s status mapping. With `max_iterations = 2` the two
// scripted tool calls exhaust the budget; the checkpoint summary call then
// draws the deterministic "reached my tool-call limit" digest.
let provider = ScriptedProvider::new(vec![
tool_response("file_read", "{\"path\":\"a\"}"),
tool_response("file_read", "{\"path\":\"a\"}"),
tool_response("file_read", "{\"path\":\"a\"}"),
tool_response("file_read", "{\"path\":\"a\"}"),
]);
let parent = make_parent(provider.clone(), vec![stub("file_read")]);
let def = make_def_named_tools(&["file_read"]);
let mut def = make_def_named_tools(&["file_read"]);
def.max_iterations = 2;
let outcome = with_parent_context(parent, async {
run_subagent(&def, "read the file", SubagentRunOptions::default()).await
})
.await
.expect("a stuck halt is still Ok (not Err)");
.expect("a cap halt is still Ok (not Err)");
match outcome.status {
SubagentRunStatus::Incomplete { reason } => assert!(
reason.contains("stuck") || reason.contains("progress"),
"incomplete reason should describe the stuck stop: {reason}"
reason.contains("limit") || reason.contains("tool-call"),
"incomplete reason should describe the cap stop: {reason}"
),
other => panic!("expected Incomplete, got {other:?}"),
}
assert!(
outcome.output.contains("same tool call"),
"the partial output should carry the repeat-call no-progress summary: {}",
outcome.output.contains("tool-call limit"),
"the partial output should carry the cap-hit checkpoint summary: {}",
outcome.output
);
}
@@ -69,10 +69,10 @@ pub struct SubagentRunOptions {
pub worktree_action_dir: Option<PathBuf>,
/// Steering channel for a running (typically async) sub-agent. When set,
/// the inner `run_turn_engine` drains steer/collect messages from this
/// queue at iteration boundaries — exactly like the main agent loop — so
/// the parent can `steer_subagent` mid-flight. `None` keeps today's
/// non-steerable behaviour.
/// the tinyagents harness drains steer/collect messages from this queue at
/// iteration boundaries — exactly like the main agent loop — so the parent
/// can `steer_subagent` mid-flight. `None` keeps today's non-steerable
/// behaviour.
pub run_queue: Option<std::sync::Arc<crate::openhuman::agent::harness::run_queue::RunQueue>>,
}
-755
View File
@@ -1,755 +0,0 @@
//! Smart-mock test support for the agent harness.
//!
//! This module provides a reusable "fake LLM" that drives the real
//! [`run_tool_call_loop`] without needing any network access. Two
//! building blocks are exposed:
//!
//! 1. [`KeywordScriptedProvider`] — a [`Provider`] implementation that
//! inspects the latest `user` message of the conversation and emits
//! canned tool calls (or a final reply) when a configured keyword
//! matches. The first turn that has *no* matching rule returns a
//! plain "done" reply, which terminates the loop deterministically.
//!
//! Compared to the hand-rolled `ScriptedProvider` in
//! [`super::tool_loop_tests`], this provider:
//! * Reacts to the rolling conversation state instead of replaying
//! a fixed queue — so tests can exercise iterative loops where
//! what the LLM does next depends on what tools returned.
//! * Supports both **native** OpenAI-style `tool_calls` and
//! **prompt-guided** `<tool_call>…</tool_call>` text — flipping
//! a single flag toggles which surface the harness exercises.
//! * Records the messages it saw and the responses it returned for
//! post-hoc assertion.
//!
//! 2. [`spawn_fake_composio_backend`] — boots a minimal axum app that
//! responds to the `/agent-integrations/composio/*` routes with
//! realistic-looking fixture data (real-world toolkit/action shapes,
//! not synthetic gibberish). Tests can pair this with a
//! [`crate::openhuman::composio::ComposioClient`] to exercise the
//! full agent → tool → backend → response flow against a hermetic
//! in-process server.
//!
//! Both helpers are `#[cfg(test)]`-only so they never leak into release
//! binaries.
use std::collections::VecDeque;
use std::sync::Arc;
use async_trait::async_trait;
use parking_lot::Mutex;
use serde_json::json;
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
use crate::openhuman::inference::provider::{
ChatMessage, ChatRequest, ChatResponse, Provider, ToolCall,
};
/// One scripted reaction the [`KeywordScriptedProvider`] can emit when
/// it sees its keyword in the latest user/tool turn.
#[derive(Debug, Clone)]
pub struct KeywordRule {
/// Substring matched (case-insensitive) against the latest user
/// or tool message in the conversation.
pub keyword: String,
/// Tool calls to emit. Empty ⇒ no tool calls, only `final_text`.
pub tool_calls: Vec<ScriptedToolCall>,
/// Optional plain-text body to include alongside any tool calls.
/// When `tool_calls` is empty, this becomes the loop-terminating
/// final response.
pub final_text: Option<String>,
/// How many times this rule may fire. `None` ⇒ unlimited.
pub max_fires: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct ScriptedToolCall {
pub name: String,
pub arguments: serde_json::Value,
}
impl ScriptedToolCall {
pub fn new(name: impl Into<String>, arguments: serde_json::Value) -> Self {
Self {
name: name.into(),
arguments,
}
}
}
impl KeywordRule {
pub fn final_reply(keyword: impl Into<String>, text: impl Into<String>) -> Self {
Self {
keyword: keyword.into(),
tool_calls: Vec::new(),
final_text: Some(text.into()),
max_fires: None,
}
}
pub fn tool_call(keyword: impl Into<String>, call: ScriptedToolCall) -> Self {
Self {
keyword: keyword.into(),
tool_calls: vec![call],
final_text: None,
max_fires: Some(1),
}
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.final_text = Some(text.into());
self
}
pub fn unlimited(mut self) -> Self {
self.max_fires = None;
self
}
}
/// Snapshot of one turn the provider served — handy for tests that
/// want to assert what the LLM "saw" without coupling to the harness
/// internals.
#[derive(Debug, Clone)]
pub struct ProviderTurn {
pub messages: Vec<ChatMessage>,
pub rule_keyword: Option<String>,
pub emitted_tool_calls: Vec<ToolCall>,
pub emitted_text: Option<String>,
}
struct ProviderState {
rules: Vec<KeywordRule>,
fired: Vec<usize>,
turns: Vec<ProviderTurn>,
fallback_text: String,
next_call_id: usize,
/// Optional queue of scripted responses to consume *before* the
/// keyword rules run — useful when a test wants the first turn to
/// behave deterministically regardless of the user message.
forced: VecDeque<ChatResponse>,
}
/// Smart provider that reacts to conversation state via keyword rules.
pub struct KeywordScriptedProvider {
state: Arc<Mutex<ProviderState>>,
native_tools: bool,
vision: bool,
}
impl KeywordScriptedProvider {
pub fn new(rules: Vec<KeywordRule>) -> Self {
Self {
state: Arc::new(Mutex::new(ProviderState {
rules,
fired: Vec::new(),
turns: Vec::new(),
fallback_text: "done".to_string(),
next_call_id: 0,
forced: VecDeque::new(),
})),
native_tools: false,
vision: false,
}
}
pub fn with_native_tools(mut self, enabled: bool) -> Self {
self.native_tools = enabled;
self
}
pub fn with_vision(mut self, enabled: bool) -> Self {
self.vision = enabled;
self
}
pub fn with_fallback(self, text: impl Into<String>) -> Self {
{
let mut guard = self.state.lock();
guard.fallback_text = text.into();
}
self
}
pub fn push_forced_response(&self, resp: ChatResponse) {
self.state.lock().forced.push_back(resp);
}
pub fn turns(&self) -> Vec<ProviderTurn> {
self.state.lock().turns.clone()
}
pub fn turn_count(&self) -> usize {
self.state.lock().turns.len()
}
}
fn latest_user_or_tool_msg(messages: &[ChatMessage]) -> Option<&ChatMessage> {
messages
.iter()
.rev()
.find(|m| m.role == "user" || m.role == "tool")
}
#[async_trait]
impl Provider for KeywordScriptedProvider {
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
native_tool_calling: self.native_tools,
vision: self.vision,
}
}
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok("fallback".into())
}
async fn chat(
&self,
request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> anyhow::Result<ChatResponse> {
let messages = request.messages.to_vec();
let mut state = self.state.lock();
// Forced queue wins, regardless of keyword matching.
if let Some(resp) = state.forced.pop_front() {
state.turns.push(ProviderTurn {
messages,
rule_keyword: None,
emitted_tool_calls: resp.tool_calls.clone(),
emitted_text: resp.text.clone(),
});
return Ok(resp);
}
let probe = latest_user_or_tool_msg(&messages)
.map(|m| m.content.to_lowercase())
.unwrap_or_default();
let mut chosen: Option<usize> = None;
for (idx, rule) in state.rules.iter().enumerate() {
let fired = *state.fired.get(idx).unwrap_or(&0);
if let Some(cap) = rule.max_fires {
if fired >= cap {
continue;
}
}
if probe.contains(&rule.keyword.to_lowercase()) {
chosen = Some(idx);
break;
}
}
let (rule_keyword, tool_calls, text) = if let Some(idx) = chosen {
while state.fired.len() <= idx {
state.fired.push(0);
}
state.fired[idx] += 1;
let rule = state.rules[idx].clone();
let tool_calls: Vec<ToolCall> = if self.native_tools {
rule.tool_calls
.iter()
.map(|c| {
let id = state.next_call_id;
state.next_call_id += 1;
ToolCall {
id: format!("call_{id}"),
name: c.name.clone(),
arguments: c.arguments.to_string(),
extra_content: None,
}
})
.collect()
} else {
Vec::new()
};
let text = if self.native_tools {
rule.final_text.clone()
} else if !rule.tool_calls.is_empty() {
// Prompt-guided: emit XML-wrapped tool calls in text.
let mut body = String::new();
if let Some(prefix) = &rule.final_text {
body.push_str(prefix);
if !prefix.ends_with('\n') {
body.push('\n');
}
}
for c in &rule.tool_calls {
body.push_str("<tool_call>");
body.push_str(&json!({"name": c.name, "arguments": c.arguments}).to_string());
body.push_str("</tool_call>\n");
}
Some(body)
} else {
rule.final_text.clone()
};
(Some(rule.keyword.clone()), tool_calls, text)
} else {
// No rule matched — emit the fallback as the final reply
// so the loop terminates rather than hanging.
(None, Vec::new(), Some(state.fallback_text.clone()))
};
let resp = ChatResponse {
text: text.clone(),
tool_calls: tool_calls.clone(),
usage: None,
reasoning_content: None,
};
state.turns.push(ProviderTurn {
messages,
rule_keyword,
emitted_tool_calls: tool_calls,
emitted_text: text,
});
Ok(resp)
}
}
// ── Fake Composio backend ──────────────────────────────────────────
use crate::openhuman::composio::ComposioClient;
use crate::openhuman::integrations::IntegrationClient;
use axum::{
extract::{Path, State},
routing::{delete, get, post},
Json, Router,
};
/// Realistic-looking Composio fixture data (toolkit slugs, action
/// names, and parameter shapes lifted from the production catalog —
/// just enough to exercise the schemas without coupling tests to the
/// upstream API).
#[derive(Default, Clone)]
pub struct ComposioFixture {
pub toolkits: Vec<String>,
pub connections: Vec<serde_json::Value>,
pub tools: Vec<serde_json::Value>,
/// Per-action canned execute responses, keyed by action slug.
pub execute_responses: std::collections::HashMap<String, serde_json::Value>,
/// Ordered request-aware execute overrides. The first matching rule wins.
pub execute_rules: Vec<ComposioExecuteRule>,
}
#[derive(Debug, Clone)]
pub struct ComposioExecuteRule {
pub action: String,
pub argument_path: Option<String>,
pub argument_contains: Option<String>,
pub response: serde_json::Value,
}
impl ComposioExecuteRule {
pub fn new(action: impl Into<String>, response: serde_json::Value) -> Self {
Self {
action: action.into(),
argument_path: None,
argument_contains: None,
response,
}
}
pub fn when_argument_contains(
mut self,
path: impl Into<String>,
needle: impl Into<String>,
) -> Self {
self.argument_path = Some(path.into());
self.argument_contains = Some(needle.into());
self
}
}
impl ComposioFixture {
/// A reasonable default fixture: GMail/Notion/GitHub connections
/// with two actions each. Use this when a test just needs *some*
/// Composio data and doesn't care about the exact shape.
pub fn realistic() -> Self {
Self {
toolkits: vec![
"gmail".to_string(),
"notion".to_string(),
"github".to_string(),
"slack".to_string(),
],
connections: vec![
json!({
"id": "conn_gmail_1",
"toolkit": "gmail",
"status": "ACTIVE",
"createdAt": "2026-04-01T12:00:00Z",
}),
json!({
"id": "conn_notion_1",
"toolkit": "notion",
"status": "ACTIVE",
"createdAt": "2026-04-02T08:00:00Z",
}),
json!({
"id": "conn_github_1",
"toolkit": "github",
"status": "ACTIVE",
"createdAt": "2026-04-03T15:30:00Z",
}),
],
tools: vec![
json!({
"name": "GMAIL_SEND_EMAIL",
"description": "Send an email via Gmail",
"parameters": {
"type": "object",
"properties": {
"recipient_email": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"},
},
"required": ["recipient_email", "subject", "body"],
},
}),
json!({
"name": "GMAIL_FETCH_EMAILS",
"description": "Fetch emails from Gmail",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"max_results": {"type": "integer"},
},
},
}),
json!({
"name": "NOTION_CREATE_PAGE",
"description": "Create a new Notion page",
"parameters": {
"type": "object",
"properties": {
"parent_id": {"type": "string"},
"title": {"type": "string"},
},
"required": ["parent_id", "title"],
},
}),
json!({
"name": "GITHUB_CREATE_ISSUE",
"description": "Open a GitHub issue",
"parameters": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"title": {"type": "string"},
"body": {"type": "string"},
},
"required": ["owner", "repo", "title"],
},
}),
],
execute_responses: [
(
"GMAIL_SEND_EMAIL".to_string(),
json!({"message_id": "gmail-msg-1234", "thread_id": "gmail-thread-9999"}),
),
(
"GMAIL_FETCH_EMAILS".to_string(),
json!({
"messages": [
{"id": "m1", "subject": "Welcome", "from": "team@openhuman.com"},
{"id": "m2", "subject": "Invoice", "from": "billing@stripe.com"},
]
}),
),
(
"NOTION_CREATE_PAGE".to_string(),
json!({"page_id": "notion-page-abc123", "url": "https://notion.so/abc123"}),
),
(
"GITHUB_CREATE_ISSUE".to_string(),
json!({
"number": 42,
"html_url": "https://github.com/example/repo/issues/42",
}),
),
]
.into_iter()
.collect(),
execute_rules: Vec::new(),
}
}
}
fn json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
path.split('.')
.filter(|segment| !segment.is_empty())
.try_fold(value, |current, segment| current.get(segment))
}
fn match_execute_rule(
rules: &[ComposioExecuteRule],
action: &str,
body: &serde_json::Value,
) -> Option<serde_json::Value> {
rules.iter().find_map(|rule| {
if rule.action != action {
return None;
}
if let Some(path) = rule.argument_path.as_deref() {
let actual = json_path(body, path)?;
if let Some(needle) = rule.argument_contains.as_deref() {
if !actual
.to_string()
.to_ascii_lowercase()
.contains(&needle.to_ascii_lowercase())
{
return None;
}
}
}
Some(rule.response.clone())
})
}
#[derive(Clone)]
struct FakeComposioState {
fixture: Arc<Mutex<ComposioFixture>>,
requests: Arc<Mutex<Vec<(String, String, serde_json::Value)>>>,
}
/// Handle to a spawned in-process Composio backend.
pub struct FakeComposioBackend {
pub base_url: String,
state: FakeComposioState,
}
impl FakeComposioBackend {
/// All `(method, path, body)` requests received in order.
pub fn requests(&self) -> Vec<(String, String, serde_json::Value)> {
self.state.requests.lock().clone()
}
/// Build a `ComposioClient` pointed at this backend.
pub fn client(&self) -> ComposioClient {
let inner = Arc::new(IntegrationClient::new(
self.base_url.clone(),
"test-token".into(),
));
ComposioClient::new(inner)
}
/// Build an `Arc<Config>` that — when passed through the mode-aware
/// factory (`create_composio_client`) — resolves to a backend
/// `ComposioClient` pointing at this fake backend, **and persist it**
/// to `config_path` on disk, returning the workspace dir to point
/// `OPENHUMAN_WORKSPACE` at.
///
/// Post-#1710-Wave-4, factory-routed tools
/// ([`crate::openhuman::composio::ComposioActionTool`],
/// `ComposioExecuteTool`, `ProviderContext`) reload config via
/// `config_rpc::load_config_with_timeout()` per call rather than
/// using the injected `Arc<Config>` — so the injected config only
/// influences routing if it is the live on-disk config the loader
/// resolves. Callers must hold `crate::openhuman::config::TEST_ENV_LOCK`
/// and `std::env::set_var("OPENHUMAN_WORKSPACE", &workspace_root)`
/// (the returned path's parent) so the loader reads this config.
///
/// Returns `(Arc<Config>, workspace_root)` where `workspace_root` is
/// the tempdir the config + auth-profile live in (the value to set
/// `OPENHUMAN_WORKSPACE` to). The tempdir is leaked so it stays
/// valid for the test's lifetime.
pub async fn config_persisted(
&self,
) -> (
std::sync::Arc<crate::openhuman::config::Config>,
std::path::PathBuf,
) {
use crate::openhuman::credentials::{
AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME,
};
let tmp = tempfile::tempdir().expect("tempdir for FakeComposioBackend::config_persisted");
let workspace_root = tmp.path().to_path_buf();
let mut config = crate::openhuman::config::Config::default();
config.workspace_dir = workspace_root.join("workspace");
config.config_path = workspace_root.join("config.toml");
config.api_url = Some(self.base_url.clone());
config.composio.mode = crate::openhuman::config::schema::COMPOSIO_MODE_BACKEND.to_string();
config.secrets.encrypt = false;
let auth = AuthService::from_config(&config);
auth.store_provider_token(
APP_SESSION_PROVIDER,
DEFAULT_AUTH_PROFILE_NAME,
"test-token",
std::collections::HashMap::new(),
true,
)
.expect("store fake app-session token for FakeComposioBackend::config_persisted");
// Persist so `load_config_with_timeout()` (resolving the workspace
// from `OPENHUMAN_WORKSPACE`) reads exactly this config.
config
.save()
.await
.expect("persist FakeComposioBackend config to disk");
std::mem::forget(tmp);
(std::sync::Arc::new(config), workspace_root)
}
}
async fn record<B: serde::Serialize + Clone>(
requests: &Arc<Mutex<Vec<(String, String, serde_json::Value)>>>,
method: &str,
path: &str,
body: Option<&B>,
) {
let body_v = match body {
Some(b) => serde_json::to_value(b).unwrap_or(serde_json::Value::Null),
None => serde_json::Value::Null,
};
requests
.lock()
.push((method.to_string(), path.to_string(), body_v));
}
/// Spawn an in-process Composio backend on `127.0.0.1:0`.
pub async fn spawn_fake_composio_backend(fixture: ComposioFixture) -> FakeComposioBackend {
let state = FakeComposioState {
fixture: Arc::new(Mutex::new(fixture)),
requests: Arc::new(Mutex::new(Vec::new())),
};
let app = Router::new()
.route(
"/agent-integrations/composio/toolkits",
get({
let st = state.clone();
move || async move {
record::<()>(&st.requests, "GET", "/toolkits", None).await;
let toolkits = st.fixture.lock().toolkits.clone();
Json(json!({
"success": true,
"data": { "toolkits": toolkits }
}))
}
}),
)
.route(
"/agent-integrations/composio/connections",
get({
let st = state.clone();
move || async move {
record::<()>(&st.requests, "GET", "/connections", None).await;
let connections = st.fixture.lock().connections.clone();
Json(json!({
"success": true,
"data": { "connections": connections }
}))
}
}),
)
.route(
"/agent-integrations/composio/tools",
get({
let st = state.clone();
move || async move {
record::<()>(&st.requests, "GET", "/tools", None).await;
let tools = st.fixture.lock().tools.clone();
Json(json!({
"success": true,
"data": { "tools": tools }
}))
}
}),
)
.route(
"/agent-integrations/composio/authorize",
post({
let st = state.clone();
move |Json(body): Json<serde_json::Value>| async move {
record(&st.requests, "POST", "/authorize", Some(&body)).await;
let toolkit = body
.get("toolkit")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
Json(json!({
"success": true,
"data": {
"connectUrl": format!("https://composio.dev/auth/{toolkit}"),
"connectionId": format!("conn_{toolkit}_pending"),
}
}))
}
}),
)
.route(
"/agent-integrations/composio/execute",
post({
let st = state.clone();
move |Json(body): Json<serde_json::Value>| async move {
record(&st.requests, "POST", "/execute", Some(&body)).await;
// ComposioClient::execute_tool sends `{tool, arguments}`.
let action = body
.get("tool")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let fx = st.fixture.lock();
let response = match_execute_rule(&fx.execute_rules, &action, &body)
.or_else(|| fx.execute_responses.get(&action).cloned())
.unwrap_or_else(|| json!({"ok": true, "action": action.clone()}));
// Wrap in the BackendResponse envelope expected by
// IntegrationClient, with the inner shape matching
// ComposioExecuteResponse.
Json(json!({
"success": true,
"data": {
"data": response,
"successful": true,
"costUsd": 0.0,
}
}))
}
}),
)
.route(
"/agent-integrations/composio/connections/{id}",
delete({
let st = state.clone();
move |Path(id): Path<String>| async move {
record::<()>(&st.requests, "DELETE", &format!("/connections/{id}"), None).await;
let mut fx = st.fixture.lock();
fx.connections
.retain(|c| c.get("id").and_then(|v| v.as_str()).unwrap_or("") != id);
Json(json!({
"success": true,
"data": {"deleted": true}
}))
}
}),
)
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
FakeComposioBackend {
base_url: format!("http://127.0.0.1:{}", addr.port()),
state,
}
}
// A handler signature placeholder to silence unused warnings when the
// State extractor isn't reached (e.g. when only sub-routes fire).
#[allow(dead_code)]
async fn _unused(_state: State<FakeComposioState>) {}
File diff suppressed because it is too large Load Diff
-139
View File
@@ -4,7 +4,6 @@ use super::parse::{
extract_json_values, parse_arguments_value, parse_glm_style_tool_calls, parse_tool_call_value,
parse_tool_calls, parse_tool_calls_from_json_value, tools_to_openai_format,
};
use super::tool_loop::{run_tool_call_loop, DEFAULT_MAX_TOOL_ITERATIONS};
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, Provider};
use crate::openhuman::tools::{self, Tool};
@@ -100,130 +99,6 @@ impl Provider for VisionProvider {
}
}
#[tokio::test]
async fn run_tool_call_loop_returns_structured_error_for_non_vision_provider() {
let calls = Arc::new(AtomicUsize::new(0));
let provider = NonVisionProvider {
calls: Arc::clone(&calls),
};
let mut history = vec![ChatMessage::user(
"please inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
)];
let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
let err = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
"mock-provider",
"mock-model",
0.0,
true,
"cli",
&crate::openhuman::config::MultimodalConfig::default(),
&crate::openhuman::config::MultimodalFileConfig::default(),
3,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.expect_err("provider without vision support should fail");
assert!(err.to_string().contains("provider_capability_error"));
assert!(err.to_string().contains("capability=vision"));
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn run_tool_call_loop_rejects_oversized_image_payload() {
let calls = Arc::new(AtomicUsize::new(0));
let provider = VisionProvider {
calls: Arc::clone(&calls),
};
let oversized_payload = STANDARD.encode(vec![0_u8; (1024 * 1024) + 1]);
let mut history = vec![ChatMessage::user(format!(
"[IMAGE:data:image/png;base64,{oversized_payload}]"
))];
let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
let multimodal = crate::openhuman::config::MultimodalConfig {
max_images: 4,
max_image_size_mb: 1,
allow_remote_fetch: false,
};
let err = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
"mock-provider",
"mock-model",
0.0,
true,
"cli",
&multimodal,
&crate::openhuman::config::MultimodalFileConfig::default(),
3,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.expect_err("oversized payload must fail");
assert!(err
.to_string()
.contains("multimodal image size limit exceeded"));
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn run_tool_call_loop_accepts_valid_multimodal_request_flow() {
let calls = Arc::new(AtomicUsize::new(0));
let provider = VisionProvider {
calls: Arc::clone(&calls),
};
let mut history = vec![ChatMessage::user(
"Analyze this [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(),
)];
let tools_registry: Vec<Box<dyn Tool>> = Vec::new();
let result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
"mock-provider",
"mock-model",
0.0,
true,
"cli",
&crate::openhuman::config::MultimodalConfig::default(),
&crate::openhuman::config::MultimodalFileConfig::default(),
3,
None,
None,
&[],
None,
None,
&crate::openhuman::tools::policy::DefaultToolPolicy,
)
.await
.expect("valid multimodal payload should pass");
assert_eq!(result, "vision-ok");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn parse_tool_calls_extracts_single_call() {
let response = r#"Let me check that.
@@ -681,20 +556,6 @@ fn extract_json_values_handles_arrays() {
assert_eq!(result.len(), 2);
}
// ═══════════════════════════════════════════════════════════════════════
// Recovery Tests - Constants Validation
// ═══════════════════════════════════════════════════════════════════════
const _: () = {
assert!(DEFAULT_MAX_TOOL_ITERATIONS > 0);
assert!(DEFAULT_MAX_TOOL_ITERATIONS <= 100);
};
#[test]
fn constants_bounds_are_compile_time_checked() {
// Bounds are enforced by the const assertions above.
}
// ═══════════════════════════════════════════════════════════════════════
// Recovery Tests - Tool Call Value Parsing
// ═══════════════════════════════════════════════════════════════════════
-696
View File
@@ -1,696 +0,0 @@
//! Pre-dispatch token budgeting for agent conversation history.
//!
//! Estimates prompt size with the same ~4 chars/token heuristic used elsewhere
//! in the codebase and drops the oldest non-system messages until the payload
//! fits the target model's context window.
use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage};
/// Tokens reserved for the model's completion, tool schemas, and provider overhead.
pub const DEFAULT_OUTPUT_RESERVE_TOKENS: u64 = 8_192;
/// Minimum reserve when the context window is very small.
const MIN_OUTPUT_RESERVE_TOKENS: u64 = 512;
/// Outcome of a pre-dispatch trim pass.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenBudgetOutcome {
pub original_tokens: usize,
pub final_tokens: usize,
pub messages_removed: usize,
pub trimmed: bool,
}
/// `[IMAGE:<data-uri>]` marker prefix. Mirrors
/// [`crate::openhuman::agent::multimodal`]: the harness embeds image
/// attachments as these markers inside the message text until the provider
/// layer promotes them into structured `image_url` parts. Kept local to avoid
/// a dependency on the (private) multimodal constant.
const IMAGE_MARKER_PREFIX: &str = "[IMAGE:";
/// Token allowance charged per `[IMAGE:…]` marker instead of counting its
/// base64 `data:` URI as text.
///
/// **Why this exists (#3205):** an attachment rides as a `[IMAGE:<base64>]`
/// marker in the message string, so an 8 MiB image is ~11 M characters. At the
/// `len/4` heuristic that reads as ~2.7 M "tokens" — orders of magnitude past
/// any context window — so the pre-dispatch budget trimmer evicted the whole
/// message *before* the image was ever extracted into `image_url` parts, and
/// the model received a text-only turn (empty/garbage response). Vision models
/// bill an image at a small fixed cost (OpenAI ≈ 851100 tokens by detail); we
/// charge a conservative upper bound so the budget stays realistic without the
/// base64 payload ever inflating it.
const IMAGE_MARKER_TOKEN_COST: usize = 1_200;
/// Rough token estimate: ~4 characters per token (matches tree summarizer).
///
/// Image markers are charged at a flat [`IMAGE_MARKER_TOKEN_COST`] rather than
/// counting their base64 payload as text — see that constant for the rationale.
/// Markerless text takes the fast path and is unchanged.
pub fn estimate_tokens(text: &str) -> usize {
if !text.contains(IMAGE_MARKER_PREFIX) {
return text.len().saturating_add(3) / 4;
}
let mut text_bytes = 0usize;
let mut images = 0usize;
let mut cursor = 0usize;
while let Some(rel) = text[cursor..].find(IMAGE_MARKER_PREFIX) {
let start = cursor + rel;
text_bytes += start - cursor; // text preceding the marker
let after = start + IMAGE_MARKER_PREFIX.len();
match text[after..].find(']') {
Some(rel_end) => {
images += 1;
cursor = after + rel_end + 1; // skip the whole marker payload
}
None => {
// Unterminated marker — count the remainder as text and stop.
text_bytes += text.len() - start;
cursor = text.len();
break;
}
}
}
text_bytes += text.len() - cursor; // trailing text after the last marker
(text_bytes.saturating_add(3) / 4)
.saturating_add(images.saturating_mul(IMAGE_MARKER_TOKEN_COST))
}
pub fn estimate_chat_message_tokens(msg: &ChatMessage) -> usize {
estimate_tokens(&msg.content)
}
pub fn estimate_conversation_message_tokens(msg: &ConversationMessage) -> usize {
match msg {
ConversationMessage::Chat(chat) => estimate_chat_message_tokens(chat),
ConversationMessage::AssistantToolCalls {
text,
tool_calls,
reasoning_content,
..
} => {
let body = text.as_deref().unwrap_or_default();
let mut total = estimate_tokens(body);
if let Some(reasoning) = reasoning_content.as_deref() {
total = total.saturating_add(estimate_tokens(reasoning));
}
for call in tool_calls {
total = total.saturating_add(estimate_tokens(&call.name));
total = total.saturating_add(estimate_tokens(&call.arguments));
}
total
}
ConversationMessage::ToolResults(results) => results
.iter()
.map(|r| estimate_tokens(&r.tool_call_id).saturating_add(estimate_tokens(&r.content)))
.sum(),
}
}
fn output_reserve_tokens(context_window: u64) -> u64 {
let pct = context_window / 10;
pct.max(MIN_OUTPUT_RESERVE_TOKENS)
.min(DEFAULT_OUTPUT_RESERVE_TOKENS.max(context_window / 4))
}
/// Token budget available for the *input* prompt after reserving room for the
/// model's completion + overhead. Public so the agent engine can detect the
/// un-evictable-prefix overflow (see [`unevictable_prefix_overflow`]).
pub fn max_input_tokens(context_window: u64) -> u64 {
context_window.saturating_sub(output_reserve_tokens(context_window))
}
/// Estimated token count of the prompt's **un-evictable prefix** — the
/// messages [`trim_chat_messages_to_budget`] can never drop: every `system`
/// message plus the single newest non-system message (the current user turn).
/// These are exactly the messages a maximal trim leaves behind, so this is the
/// floor below which trimming cannot take the prompt.
fn unevictable_prefix_tokens(messages: &[ChatMessage]) -> usize {
let newest_non_system = messages.iter().rposition(|m| m.role != "system");
messages
.iter()
.enumerate()
.filter(|(idx, m)| m.role == "system" || Some(*idx) == newest_non_system)
.map(|(_, m)| estimate_chat_message_tokens(m))
.sum()
}
/// The actionable, user-facing condition behind Sentry TAURI-RUST-6V0: a local
/// model was loaded with a context window (`context_window`, the runtime
/// `n_ctx`) smaller than the assistant's un-evictable system/prompt prefix
/// (`prefix_tokens`, the runtime `n_keep`). Trimming can evict conversation
/// history but never the system prefix or the current turn, so it can never get
/// the prompt under budget — the request is doomed to the opaque upstream
/// `400 (n_keep >= n_ctx)`. We detect it pre-dispatch and surface the remedy the
/// user actually controls: reload the model with a larger context length.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"Your local model's context length ({context_window} tokens) is smaller than \
the assistant's required system prompt (~{prefix_tokens} tokens), so the \
prompt can't be trimmed to fit (n_keep >= n_ctx). Reload the model in your \
local server (e.g. LM Studio) with a larger context length, then try again."
)]
pub struct ContextPrefixTooLargeError {
/// Tokens in the un-evictable prefix (system + current turn) — the `n_keep`.
pub prefix_tokens: usize,
/// The model's effective (runtime-loaded) context window — the `n_ctx`.
pub context_window: u64,
/// The derived input budget the prefix must fit within.
pub max_input_tokens: u64,
}
/// Detect the [`ContextPrefixTooLargeError`] condition: the un-evictable prefix
/// alone overflows the model's runtime context window, so even a maximal trim
/// (which can only drop evictable history) can never get the prompt to fit.
/// Returns `Some(err)` only when the prompt can *never* fit — i.e. the prefix is
/// at least as large as the whole `context_window` (`n_keep >= n_ctx`), the exact
/// condition the runtime rejects — so the caller surfaces the actionable error
/// instead of dispatching. `None` when trimming can keep the prompt within
/// budget.
///
/// The predicate is anchored on `context_window` (the runtime `n_ctx`), **not**
/// `max_input_tokens` (which subtracts a conservative reply reserve). Comparing
/// against `max_input_tokens` would abort valid requests whose prefix fits inside
/// the context window but exceeds the reply reserve — those are over-trim
/// candidates (the reply just gets less room), not the un-fixable
/// `n_keep >= n_ctx` failure this actionable error is meant to report (#3550 /
/// Sentry TAURI-RUST-6V0; CodeRabbit review on PR #3771).
pub fn unevictable_prefix_overflow(
messages: &[ChatMessage],
context_window: u64,
) -> Option<ContextPrefixTooLargeError> {
let max_input = max_input_tokens(context_window);
let prefix_tokens = unevictable_prefix_tokens(messages);
// `n_keep >= n_ctx`: the un-evictable prefix is at least the whole runtime
// window. This is the precise condition the local runtime rejects, and the
// only one no trim can rescue.
(prefix_tokens as u64 >= context_window).then_some(ContextPrefixTooLargeError {
prefix_tokens,
context_window,
max_input_tokens: max_input,
})
}
/// Trim `messages` oldest-first (never removing `system` role) until the
/// estimated prompt fits `context_window`.
pub fn trim_chat_messages_to_budget(
messages: &mut Vec<ChatMessage>,
context_window: u64,
) -> TokenBudgetOutcome {
trim_messages_to_budget(
messages,
context_window,
estimate_chat_message_tokens,
|msg| msg.role == "system",
|msg| msg.role == "tool",
)
}
/// Trim conversation `history` oldest-first, preserving system chat messages.
pub fn trim_conversation_history_to_budget(
history: &mut Vec<ConversationMessage>,
context_window: u64,
) -> TokenBudgetOutcome {
trim_messages_to_budget(
history,
context_window,
estimate_conversation_message_tokens,
|msg| matches!(msg, ConversationMessage::Chat(c) if c.role == "system"),
|msg| matches!(msg, ConversationMessage::ToolResults(_)),
)
}
fn trim_messages_to_budget<T, F, P, R>(
messages: &mut Vec<T>,
context_window: u64,
estimate: F,
is_system: P,
is_tool_result: R,
) -> TokenBudgetOutcome
where
F: Fn(&T) -> usize,
P: Fn(&T) -> bool,
R: Fn(&T) -> bool,
{
let max_tokens = max_input_tokens(context_window) as usize;
let original_tokens: usize = messages.iter().map(&estimate).sum();
if original_tokens <= max_tokens {
return TokenBudgetOutcome {
original_tokens,
final_tokens: original_tokens,
messages_removed: 0,
trimmed: false,
};
}
// Drop oldest non-system messages until the budget fits, preserving the
// original relative order of every retained message (system + non-system).
// Rebuilding as `system ++ other` would reorder history when a system
// message appears after non-system messages, which changes prompt
// semantics (see PR #2100 CodeRabbit review).
let mut removable_positions: Vec<usize> = messages
.iter()
.enumerate()
.filter_map(|(idx, msg)| (!is_system(msg)).then_some(idx))
.collect();
let mut removed = 0usize;
while !removable_positions.is_empty() {
let total: usize = messages.iter().map(&estimate).sum();
if total <= max_tokens {
break;
}
let absolute_idx = removable_positions.remove(0);
// Subsequent positions shift left by one for every prior removal.
let remove_at = absolute_idx - removed;
messages.remove(remove_at);
removed += 1;
}
// Snap the window forward past any leading orphaned tool results.
//
// Oldest-first eviction removes whole messages from the front, so it can
// drop an `assistant(tool_calls)` while keeping the `tool` result(s) that
// answered it — leaving the window opening on a tool message with no
// preceding `tool_calls`. The provider rejects that with a 400 (`messages
// with role 'tool' must be a response to a preceding message with
// 'tool_calls'`), which streams back empty and surfaces as a generic
// "Something went wrong". Drop leading tool-result messages (covering both
// single and parallel tool cycles) until the first non-system message is a
// clean turn boundary. Mirrors `session::turn::trim_history`'s orphan-snap
// and the summarizer's `snap_split_forward`; the wire-boundary
// `enforce_tool_message_invariants` remains the final repair.
while let Some(first_non_system) = messages.iter().position(|m| !is_system(m)) {
if is_tool_result(&messages[first_non_system]) {
messages.remove(first_non_system);
removed += 1;
} else {
break;
}
}
let final_tokens: usize = messages.iter().map(&estimate).sum();
TokenBudgetOutcome {
original_tokens,
final_tokens,
messages_removed: removed,
trimmed: removed > 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::inference::provider::ToolCall;
fn user_msg(content: &str) -> ChatMessage {
ChatMessage::user(content.to_string())
}
#[test]
fn estimate_tokens_charges_flat_cost_per_image_marker_not_base64_length() {
// A ~120k-char base64 data URI inside an `[IMAGE:]` marker must NOT be
// counted as text (that read as ~30k tokens and got the image trimmed,
// #3205). It should cost a flat IMAGE_MARKER_TOKEN_COST plus the small
// surrounding text, regardless of payload size.
let surrounding = "describe this picture";
let big_uri = format!("data:image/png;base64,{}", "Q".repeat(120_000));
let with_image = format!("{surrounding} [IMAGE:{big_uri}]");
let est = estimate_tokens(&with_image);
let text_only = estimate_tokens(surrounding);
assert_eq!(est, text_only.saturating_add(IMAGE_MARKER_TOKEN_COST));
assert!(
est < 2_000,
"image marker must not be counted by base64 length (got {est})"
);
}
#[test]
fn estimate_tokens_counts_each_image_marker_once() {
let two = "[IMAGE:data:image/png;base64,AAA] and [IMAGE:https://x/y.jpg]";
let est = estimate_tokens(two);
assert!(est >= 2 * IMAGE_MARKER_TOKEN_COST);
assert!(est < 2 * IMAGE_MARKER_TOKEN_COST + 50);
}
#[test]
fn estimate_tokens_markerless_text_uses_char_heuristic() {
let text = "x".repeat(400);
assert_eq!(estimate_tokens(&text), 100);
}
#[test]
fn image_marker_message_is_not_trimmed_within_budget() {
// End-to-end: a huge base64 image in the newest user turn must survive
// the budget trim (it used to be evicted as ~30k tokens).
let big = format!("look [IMAGE:data:image/png;base64,{}]", "Q".repeat(150_000));
let mut messages = vec![ChatMessage::system("sys"), user_msg(&big)];
let outcome = trim_chat_messages_to_budget(&mut messages, 200_000);
assert!(
!outcome.trimmed,
"image message must fit the budget, not be trimmed"
);
assert_eq!(messages.len(), 2);
assert!(messages[1].content.contains("[IMAGE:"));
}
#[test]
fn under_limit_passes_through_unchanged() {
let mut messages = vec![
ChatMessage::system("sys"),
user_msg("hello"),
ChatMessage::assistant("hi"),
];
let before_len = messages.len();
let outcome = trim_chat_messages_to_budget(&mut messages, 100_000);
assert!(!outcome.trimmed);
assert_eq!(outcome.original_tokens, outcome.final_tokens);
assert_eq!(messages.len(), before_len);
}
#[test]
fn over_limit_truncates_oldest_non_system_first() {
let mut messages = vec![
ChatMessage::system("system prompt"),
user_msg(&"x".repeat(400_000)),
user_msg("keep-me"),
];
let outcome = trim_chat_messages_to_budget(&mut messages, 1_000);
assert!(outcome.trimmed);
assert!(outcome.final_tokens < outcome.original_tokens);
assert!(outcome.messages_removed >= 1);
assert_eq!(messages.first().unwrap().role, "system");
assert!(
messages.iter().any(|m| m.content.contains("keep-me")),
"newest user message should survive trimming"
);
}
#[test]
fn conservative_local_floor_bounds_oversized_history() {
// TAURI-RUST-6V0 / #3550: when a local provider resolves a conservative
// fallback window (instead of `None`, which would skip trimming and let
// the prompt overflow the runtime `n_ctx` → a hard 400), trimming MUST
// still engage and leave the prompt BOUNDED — not unbounded. Mirrors the
// 4096-token floor returned by
// `context_window_for_model_with_local_fallback` for a local provider
// with no profile default.
let floor: u64 = 4_096;
let mut messages = vec![
ChatMessage::system("system prompt"),
user_msg(&"x".repeat(400_000)), // ~100k tokens, far over the floor
user_msg("newest"),
];
let outcome = trim_chat_messages_to_budget(&mut messages, floor);
assert!(outcome.trimmed, "oversized history must be trimmed");
// The retained prompt must fit the input budget derived from the floor —
// i.e. it is bounded, never left at the original ~100k tokens.
let max_input = max_input_tokens(floor) as usize;
assert!(
outcome.final_tokens <= max_input,
"trimmed prompt ({} tokens) must fit the conservative floor budget ({max_input})",
outcome.final_tokens
);
assert!(outcome.final_tokens < outcome.original_tokens);
// The newest user turn survives.
assert!(messages.iter().any(|m| m.content == "newest"));
}
#[test]
fn unevictable_prefix_overflow_fires_when_system_prefix_exceeds_local_ctx() {
// TAURI-RUST-6V0: a large system prompt (the un-evictable `n_keep`)
// alone exceeds a small local context window (`n_ctx`). Trimming can
// never help — it never drops the system message — so the pre-dispatch
// guard must fire with the numbers needed for the actionable error.
let ctx: u64 = 8_192; // LM Studio loaded n_ctx from the live events
let mut messages = vec![
// ~11k-token system prefix (the reported n_keep ≈ 10978).
ChatMessage::system(&"s".repeat(44_000)),
user_msg("hi"),
];
// Even a maximal trim leaves the prompt over budget.
let outcome = trim_chat_messages_to_budget(&mut messages, ctx);
assert!(
outcome.final_tokens as u64 > max_input_tokens(ctx),
"precondition: trim cannot fit the prompt"
);
let overflow = unevictable_prefix_overflow(&messages, ctx)
.expect("un-evictable prefix overflow must be detected");
assert_eq!(overflow.context_window, ctx);
assert!(
overflow.prefix_tokens as u64 > overflow.max_input_tokens,
"prefix must exceed the input budget"
);
// The user-facing remedy + the diagnostic anchor are both present.
let msg = overflow.to_string();
assert!(
msg.contains("larger context length"),
"must name the remedy"
);
assert!(
msg.contains("n_keep >= n_ctx"),
"must carry the diagnostic anchor"
);
}
#[test]
fn unevictable_prefix_overflow_anchors_on_context_window_not_reply_reserve() {
// CodeRabbit boundary case (PR #3771): a prefix that lands in the band
// `max_input_tokens < prefix < context_window` fits the runtime `n_ctx`
// — the reply just gets less room — so it MUST NOT be aborted as the
// un-fixable `n_keep >= n_ctx` failure. Anchoring on `max_input_tokens`
// (the old `>` predicate) would have wrongly aborted it.
let ctx: u64 = 8_192;
// output_reserve = max(819, min(8192/4=2048, 8192/10=819)) = 819, so
// max_input = 8192 - 819 = 7373.
let max_input = max_input_tokens(ctx);
// ~7700-token system prefix: above max_input (7373), below ctx (8192).
let messages = vec![
ChatMessage::system(&"s".repeat(30_800)), // (30800+3)/4 = 7700 tokens
user_msg("hi"),
];
let prefix = unevictable_prefix_tokens(&messages) as u64;
assert!(
prefix > max_input && prefix < ctx,
"test precondition: prefix ({prefix}) must sit between max_input \
({max_input}) and context_window ({ctx})"
);
assert!(
unevictable_prefix_overflow(&messages, ctx).is_none(),
"prefix that fits the context window (just over the reply reserve) \
must NOT be aborted — it is an over-trim case, not n_keep >= n_ctx"
);
// The same prefix against a context window it actually overflows
// (prefix >= n_ctx) MUST fire.
let small_ctx: u64 = prefix; // n_ctx == n_keep ⇒ the boundary itself fires
assert!(
unevictable_prefix_overflow(&messages, small_ctx).is_some(),
"prefix >= context_window (n_keep >= n_ctx) must be detected"
);
}
#[test]
fn unevictable_prefix_overflow_none_when_prompt_fits() {
// A small prompt against a normal window: trimming isn't needed and the
// guard must NOT fire (no false-positive actionable error).
let messages = vec![ChatMessage::system("short system"), user_msg("hello")];
assert!(unevictable_prefix_overflow(&messages, 8_192).is_none());
}
#[test]
fn unevictable_prefix_overflow_counts_newest_turn_not_old_history() {
// The un-evictable prefix is system + the NEWEST non-system turn. Old
// history is evictable, so a huge oldest turn must NOT, by itself,
// trigger the guard when the system prefix + newest turn fit.
let ctx: u64 = 32_000;
let messages = vec![
ChatMessage::system("small system"),
user_msg(&"x".repeat(400_000)), // oldest, evictable — ~100k tokens
user_msg("newest small turn"),
];
assert!(
unevictable_prefix_overflow(&messages, ctx).is_none(),
"evictable old history must not count toward the un-evictable prefix"
);
}
#[test]
fn trim_conversation_history_drops_oldest_messages() {
let mut messages = vec![ConversationMessage::Chat(user_msg(&"y".repeat(80_000)))];
let outcome = trim_conversation_history_to_budget(&mut messages, 1_000);
assert!(outcome.trimmed);
assert!(outcome.original_tokens > outcome.final_tokens);
}
#[test]
fn conversation_tool_results_are_counted_in_estimate() {
let msg = ConversationMessage::ToolResults(vec![
crate::openhuman::inference::provider::ToolResultMessage {
tool_call_id: "c1".into(),
content: "z".repeat(8_000),
},
]);
assert!(estimate_conversation_message_tokens(&msg) > 1_000);
}
#[test]
fn trim_preserves_relative_order_when_system_appears_late() {
// System message in the middle of history must not be moved to the
// front during trimming. Regression guard for PR #2100 review.
let mut messages = vec![
user_msg(&"a".repeat(40_000)), // oldest non-system, expected to drop
user_msg("first-user"),
ChatMessage::system("late-system"),
user_msg("last-user"),
];
let outcome = trim_chat_messages_to_budget(&mut messages, 1_000);
assert!(outcome.trimmed);
// System position relative to surrounding messages is preserved.
let roles: Vec<&str> = messages.iter().map(|m| m.role.as_str()).collect();
let sys_idx = roles
.iter()
.position(|r| *r == "system")
.expect("system message must be retained");
// At least one user message should still precede the late system message.
assert!(
sys_idx > 0,
"late system message must remain after earlier surviving non-system messages"
);
assert!(
messages.iter().any(|m| m.content == "last-user"),
"newest user message must survive"
);
}
#[test]
fn assistant_tool_calls_estimate_includes_arguments() {
let msg = ConversationMessage::AssistantToolCalls {
text: Some("thinking".into()),
tool_calls: vec![ToolCall {
id: "1".into(),
name: "echo".into(),
arguments: "{\"value\":\"x\"}".into(),
extra_content: None,
}],
reasoning_content: None,
extra_metadata: None,
};
assert!(estimate_conversation_message_tokens(&msg) > 0);
}
fn tool_results(ids: &[&str]) -> ConversationMessage {
ConversationMessage::ToolResults(
ids.iter()
.map(
|id| crate::openhuman::inference::provider::ToolResultMessage {
tool_call_id: (*id).into(),
content: format!("result-{id}"),
},
)
.collect(),
)
}
fn tool_call(id: &str) -> ToolCall {
ToolCall {
id: id.into(),
name: "f".into(),
arguments: "{}".into(),
extra_content: None,
}
}
#[test]
fn chat_budget_trim_snaps_past_orphaned_tool_result() {
// Oldest-first eviction drops the assistant turn and would leave the
// `tool` result as a leading orphan (provider 400). The snap must drop
// it so the window opens on a clean turn boundary.
let mut messages = vec![
ChatMessage::system("sys"),
ChatMessage::assistant(&"a".repeat(400_000)), // oldest non-system → evicted
ChatMessage::tool("result for the evicted tool call"),
user_msg("keep-me"),
];
let outcome = trim_chat_messages_to_budget(&mut messages, 1_000);
assert!(outcome.trimmed);
let first_non_system = messages
.iter()
.find(|m| m.role != "system")
.expect("a non-system message survives");
assert_ne!(
first_non_system.role, "tool",
"leading orphan tool result must be snapped away, not sent to the provider"
);
assert!(messages.iter().any(|m| m.content == "keep-me"));
}
#[test]
fn conversation_budget_trim_snaps_past_orphaned_parallel_tool_results() {
// A parallel cycle: one AssistantToolCalls answered by two ToolResults.
// Evicting the call must not leave either result orphaned at the head.
let mut history = vec![
ConversationMessage::Chat(ChatMessage::system("sys")),
ConversationMessage::AssistantToolCalls {
text: Some("x".repeat(400_000)), // oldest non-system → evicted
tool_calls: vec![tool_call("X"), tool_call("Y")],
reasoning_content: None,
extra_metadata: None,
},
tool_results(&["X"]),
tool_results(&["Y"]),
ConversationMessage::Chat(user_msg("keep-me")),
];
let outcome = trim_conversation_history_to_budget(&mut history, 1_000);
assert!(outcome.trimmed);
let first_non_system = history
.iter()
.find(|m| !matches!(m, ConversationMessage::Chat(c) if c.role == "system"))
.expect("a non-system message survives");
assert!(
!matches!(first_non_system, ConversationMessage::ToolResults(_)),
"leading orphan tool results must be snapped away"
);
assert!(history
.iter()
.any(|m| matches!(m, ConversationMessage::Chat(c) if c.content == "keep-me")));
}
#[test]
fn conversation_budget_trim_keeps_paired_call_and_result_at_window_head() {
// When the post-trim window opens on an assistant(tool_calls) followed
// by its result, the snap must NOT remove the (validly paired) result.
let mut history = vec![
ConversationMessage::Chat(user_msg(&"x".repeat(400_000))), // evicted
ConversationMessage::AssistantToolCalls {
text: None,
tool_calls: vec![tool_call("A")],
reasoning_content: None,
extra_metadata: None,
},
tool_results(&["A"]),
ConversationMessage::Chat(user_msg("keep")),
];
let outcome = trim_conversation_history_to_budget(&mut history, 1_000);
assert!(outcome.trimmed);
assert!(
matches!(
history.first(),
Some(ConversationMessage::AssistantToolCalls { .. })
),
"window should open on the surviving tool call"
);
assert!(
history
.iter()
.any(|m| matches!(m, ConversationMessage::ToolResults(_))),
"a validly-paired tool result must be retained, not over-snapped"
);
}
}
-709
View File
@@ -1,709 +0,0 @@
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::inference::provider::{ChatMessage, Provider, AGENT_TURN_MAX_OUTPUT_TOKENS};
use crate::openhuman::tools::policy::{DefaultToolPolicy, ToolPolicy};
use crate::openhuman::tools::Tool;
use anyhow::Result;
use std::collections::HashSet;
use super::payload_summarizer::PayloadSummarizer;
/// Minimum characters per chunk when relaying LLM text to a streaming draft.
pub(crate) const STREAM_CHUNK_MIN_CHARS: usize = 80;
/// Default maximum agentic tool-use iterations per user message to prevent runaway loops.
/// Used as a safe fallback when `max_tool_iterations` is unset or configured as zero.
pub(crate) const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10;
/// Extended iteration cap for agents with `IterationPolicy::Extended`. These
/// are multi-step specialists (code executor, integrations, planner, …) whose
/// realistic workflows commonly exceed the default 10-iteration cap. The
/// repeated-failure circuit breaker and cost budget remain the primary runaway
/// guards; this value is intentionally generous to avoid premature stops.
pub(crate) const EXTENDED_MAX_TOOL_ITERATIONS: usize = 50;
/// Repeated-failure circuit breaker. The plain iteration cap lets an agent grind
/// the same dead-end (e.g. re-running `pip install` when there is no pip) until
/// `max_iterations`, then return an opaque `MaxIterationsExceeded` that the caller
/// just re-spawns — losing the failure context. These thresholds let the loop bail
/// EARLY with a root-cause summary instead.
///
/// If the SAME `(tool, args)` call fails this many times, the agent is repeating a
/// known-failed action verbatim — stop.
pub(crate) const REPEAT_FAILURE_THRESHOLD: u32 = 3;
/// Recoverable/transient failures (timeouts, connection resets, rate limits, ...)
/// are still bounded, but need more room than deterministic terminal failures so
/// the model can adapt (change timeout, narrow work, split a command, retry a
/// flaky network call) before the breaker stops the turn.
pub(crate) const RECOVERABLE_REPEAT_FAILURE_THRESHOLD: u32 = 8;
/// If this many non-recoverable tool calls fail back-to-back with no success in
/// between (even with varied args), the agent is making no progress — stop.
pub(crate) const NO_PROGRESS_FAILURE_THRESHOLD: u32 = 6;
/// Recoverable failures get a separate, larger no-progress headroom. The
/// iteration cap and cost budget still bound the turn, while a handful of
/// timeouts no longer stops an otherwise adaptable agent.
pub(crate) const RECOVERABLE_NO_PROGRESS_FAILURE_THRESHOLD: u32 = 12;
/// Hard policy rejections (a security block or a gate denial) are deterministic:
/// the identical `(tool, args)` call provably cannot succeed. Halt on the FIRST
/// verbatim repeat — i.e. the second identical attempt — rather than letting the
/// agent burn the generic [`REPEAT_FAILURE_THRESHOLD`] on a doomed call. The first
/// occurrence is allowed through so the model can read the "do not retry" reason
/// and pivot to a different, allowed approach.
pub(crate) const HARD_REJECT_REPEAT_THRESHOLD: u32 = 2;
/// Classification of a deterministic, recognizable policy rejection, detected via
/// the stable markers the security/approval layers emit
/// ([`crate::openhuman::security::POLICY_BLOCKED_MARKER`] /
/// [`POLICY_DENIED_MARKER`](crate::openhuman::security::POLICY_DENIED_MARKER)).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum HardReject {
/// Permanent for this tier (read-only write, forbidden/credential path,
/// disallowed command) — never succeeds on retry.
Blocked,
/// User denied / approval timed out this turn — re-asking the identical call
/// only re-prompts.
Denied,
}
/// Recognize a hard policy rejection from a tool result. Matches anywhere in the
/// string (not just the prefix) so it survives the `Error: …` wrapping the tool
/// layer adds. `Blocked` takes precedence over `Denied` if both somehow appear.
pub(crate) fn hard_reject_kind(result: &str) -> Option<HardReject> {
if result.contains(crate::openhuman::security::POLICY_BLOCKED_MARKER) {
Some(HardReject::Blocked)
} else if result.contains(crate::openhuman::security::POLICY_DENIED_MARKER) {
Some(HardReject::Denied)
} else {
None
}
}
/// A permanent, non-retryable inference failure surfaced by a tool result —
/// typically a delegated sub-agent (`run_code` / `tools_agent` / `plan`) whose
/// provider call hit a user-state wall. Unlike a transient error, re-issuing the
/// call cannot succeed even under a *different* delegation tool or varied args:
/// the budget is account-wide and the model/provider configuration is shared by
/// every sub-agent. See [`terminal_inference_failure_kind`].
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum TerminalInferenceFailure {
/// Out of inference budget / credits — every retry hits the same wall.
/// Detected via
/// [`is_budget_exhausted_message`](crate::openhuman::inference::provider::is_budget_exhausted_message).
BudgetExhausted,
/// The configured model/provider rejected the request for a reason the user
/// must fix (unknown model, non-chat/embedding model, missing credential,
/// region block, …). Detected via
/// [`is_provider_config_rejection_message`](crate::openhuman::inference::provider::is_provider_config_rejection_message).
ProviderConfig,
}
/// Inference/delegation **envelope** markers that prove a tool result came from
/// a delegated inference call (a sub-agent / provider round-trip) rather than
/// from arbitrary tool stderr.
///
/// The two provider classifiers ([`is_budget_exhausted_message`] /
/// [`is_provider_config_rejection_message`]) match on short message substrings
/// (`"insufficient balance"`, `"invalid temperature"`, `"model field is
/// required"`, …) that can legitimately appear in a *recoverable* tool's output
/// — e.g. a `shell`/`run_code` script printing `ValueError: invalid temperature`
/// or a test asserting on `"model field is required"`. Applying the terminal
/// halt to those would misreport a fixable script failure as "fix the model or
/// API key" and stop after a single attempt.
///
/// Gating on these envelope markers scopes the classifier to genuinely
/// delegated inference failures. Every marker here is **harness-generated** —
/// produced by our own reliable-chain rollup or sub-agent dispatch wrapper, NOT
/// by a provider HTTP body that arbitrary tool stderr could forge:
/// * the reliable-chain exhaustion rollup (`"All providers/models failed"` /
/// `"may not be available on your provider"`, reliable.rs), and
/// * the sub-agent dispatch wrapper (`"failed and did not complete"`, see
/// [`crate::openhuman::agent_orchestration::tools::dispatch::format_subagent_failure`]).
///
/// **Why the bare provider envelope is NOT a marker (Codex review #3779):** the
/// raw provider-HTTP shape (`"<provider> API error (…)"`, `"<provider> Responses
/// API error: …"`) is reproducible verbatim by a *recoverable* tool that is
/// debugging its own API client — e.g. a `shell`/`run_code` script printing
/// `OpenAI API error (400): invalid temperature` or `… model field is required`.
/// Matching the bare `"api error"` substring there would let that script trip
/// the broad provider-config classifier and HALT the whole turn after a single
/// failed command with a misleading "fix your model in Settings → AI" message,
/// even though the agent should just recover. Every *genuine* delegated
/// inference failure additionally surfaces through one of the harness wrappers
/// above (a sub-agent provider error reaches the orchestrator only via
/// `dispatch::format_subagent_failure`; a direct reliable-chain exhaustion via
/// the rollup), so dropping the bare provider envelope loses no real detection
/// while closing the false-positive on tool stderr.
///
/// [`is_budget_exhausted_message`]: crate::openhuman::inference::provider::is_budget_exhausted_message
/// [`is_provider_config_rejection_message`]: crate::openhuman::inference::provider::is_provider_config_rejection_message
const INFERENCE_FAILURE_ENVELOPE_MARKERS: &[&str] = &[
// Reliable-chain exhaustion rollup (reliable.rs::format_failure_aggregate).
"all providers/models failed",
"may not be available on your provider",
// Sub-agent delegation failure wrapper (dispatch.rs::format_subagent_failure).
"failed and did not complete",
];
/// True if `result` carries one of the inference/delegation envelope markers —
/// i.e. the failure demonstrably came from a delegated provider round-trip, not
/// from an arbitrary tool's stderr. See [`INFERENCE_FAILURE_ENVELOPE_MARKERS`].
fn has_inference_failure_envelope(result: &str) -> bool {
let lower = result.to_ascii_lowercase();
INFERENCE_FAILURE_ENVELOPE_MARKERS
.iter()
.any(|marker| lower.contains(marker))
}
/// Recognize a permanent (non-retryable) inference failure from a tool result.
///
/// Two-stage gate so a *recoverable* tool failure can't be misclassified:
/// 1. The result must carry a delegated-inference **envelope**
/// ([`has_inference_failure_envelope`]) — proving it came from a sub-agent
/// / provider round-trip and not from arbitrary tool stderr that merely
/// happens to contain a classifier substring (e.g. a `shell` script
/// printing `ValueError: invalid temperature` or a test asserting on
/// `"model field is required"`). Without this guard a fixable script/test
/// failure would be misreported as "fix the model or API key" and stopped
/// after a single attempt (Codex review #3779).
/// 2. The (then-trusted) body is matched against the two deliberately-tight
/// provider classifiers, which stay in lockstep with the Sentry-demotion
/// phrase sets: a transient / 5xx / generic 4xx body matches NEITHER, so
/// genuinely retryable failures still get the normal consecutive-failure
/// grace ([`NO_PROGRESS_FAILURE_THRESHOLD`]) and are never halted early.
/// Budget takes precedence if both somehow match.
///
/// The orchestrator otherwise re-emits a failed delegation under *varied* tool
/// names (Plan → `run_code` → `tools_agent`), so the identical-`(tool,args)`
/// [`REPEAT_FAILURE_THRESHOLD`] never trips and the chain grinds through ~6-8
/// doomed, paid delegations before [`NO_PROGRESS_FAILURE_THRESHOLD`] finally
/// halts with an opaque "Something went wrong" (#3104). Tripping on the FIRST
/// permanent failure stops that cascade and surfaces the root cause.
pub(crate) fn terminal_inference_failure_kind(result: &str) -> Option<TerminalInferenceFailure> {
use crate::openhuman::inference::provider::{
is_budget_exhausted_message, is_provider_config_rejection_message,
};
// Require the delegated-inference envelope first: the message-only
// classifiers are too broad to apply to arbitrary tool stderr.
if !has_inference_failure_envelope(result) {
return None;
}
if is_budget_exhausted_message(result) {
Some(TerminalInferenceFailure::BudgetExhausted)
} else if is_provider_config_rejection_message(result) {
Some(TerminalInferenceFailure::ProviderConfig)
} else {
None
}
}
/// Failures that are informative and plausibly recoverable by changing the next
/// action (longer timeout, smaller batch, different network retry/fallback)
/// rather than by immediately abandoning the turn.
///
/// Keep this deliberately marker-based and conservative: it only controls
/// breaker headroom, never converts a failure into success. Hard policy rejects
/// and permanent provider/account failures are classified before this function.
pub(crate) fn is_recoverable_tool_failure(result: &str) -> bool {
let lower = result.to_ascii_lowercase();
[
"timed out",
"timeout",
"deadline exceeded",
"temporarily unavailable",
"temporary failure",
"connection reset",
"connection refused",
"connection closed",
"connection aborted",
"network is unreachable",
"host is unreachable",
"dns error",
"failed to lookup address",
"failed to resolve",
"rate limit",
"too many requests",
"retry after",
"503 service unavailable",
"502 bad gateway",
"504 gateway timeout",
]
.iter()
.any(|marker| lower.contains(marker))
}
/// Shared repeated-failure circuit breaker, used by BOTH agent loops
/// (`run_tool_call_loop` here and `run_inner_loop` in `subagent_runner`) so they
/// can't drift. Tracks per-`(tool,args)`-signature failure counts and a
/// consecutive-failure run within a single agent turn; [`Self::record`] returns
/// a root-cause halt summary once a threshold trips.
#[derive(Default)]
pub(crate) struct RepeatFailureGuard {
sig_counts: std::collections::HashMap<String, u32>,
consecutive: u32,
consecutive_recoverable: u32,
}
impl RepeatFailureGuard {
pub(crate) fn new() -> Self {
Self::default()
}
/// Record one tool-call outcome. `args_sig` is a stable string form of the
/// arguments (e.g. the command). Returns `Some(summary)` when the breaker
/// trips — the caller should stop the loop and return that summary as the
/// agent's result instead of grinding to `max_iterations`.
pub(crate) fn record(
&mut self,
tool: &str,
args_sig: &str,
success: bool,
result: &str,
) -> Option<String> {
if success {
self.consecutive = 0;
self.consecutive_recoverable = 0;
return None;
}
let count = {
let c = self
.sig_counts
.entry(format!("{tool}|{args_sig}"))
.or_insert(0);
*c += 1;
*c
};
// Permanent inference failures (out of budget / provider-config rejection)
// cannot be recovered by retrying — the budget is account-wide and the
// model/provider config is shared by every (sub-)agent. Halt on the FIRST
// occurrence with an actionable root cause instead of letting the
// orchestrator re-emit the step under varied delegation-tool names until
// NO_PROGRESS_FAILURE_THRESHOLD (#3104: Plan → Run Code ×6 → Tools Agent
// ×2). Checked before the count-based thresholds precisely because those
// never trip in time when the tool name keeps changing.
if let Some(kind) = terminal_inference_failure_kind(result) {
tracing::warn!(
tool,
kind = ?kind,
"[agent_loop] permanent inference failure — halting on first occurrence with root cause"
);
return Some(match kind {
TerminalInferenceFailure::BudgetExhausted => format!(
"Stopping: the `{tool}` step failed because the account is out of inference \
budget/credits — every retry hits the same wall. Add credits to your account \
(or, when using a custom/BYO provider, top up that provider's own account) \
and try again. Details:\n{}",
truncate_for_halt(result),
),
TerminalInferenceFailure::ProviderConfig => format!(
"Stopping: the `{tool}` step failed because the configured model/provider \
rejected the request (e.g. an unknown model, a non-chat/embedding model, a \
missing credential, or a region block) — retrying will not help. Fix the model \
or API key in Settings → AI. Details:\n{}",
truncate_for_halt(result),
),
});
}
// Hard policy rejections trip on the first verbatim repeat; recoverable
// failures get extra headroom; everything else uses the generic
// identical-retry threshold.
let hard = hard_reject_kind(result);
let recoverable = hard.is_none() && is_recoverable_tool_failure(result);
if recoverable {
self.consecutive_recoverable += 1;
tracing::debug!(
tool,
count,
consecutive_recoverable = self.consecutive_recoverable,
"[agent_loop] recoverable tool failure recorded with extended circuit-breaker headroom"
);
} else {
self.consecutive += 1;
self.consecutive_recoverable = 0;
}
let repeat_threshold = if hard.is_some() {
HARD_REJECT_REPEAT_THRESHOLD
} else if recoverable {
RECOVERABLE_REPEAT_FAILURE_THRESHOLD
} else {
REPEAT_FAILURE_THRESHOLD
};
if count >= repeat_threshold {
return Some(match hard {
Some(HardReject::Blocked) => format!(
"Stopping: the `{tool}` call is blocked by the security policy and was \
re-issued with identical arguments — it can never succeed this way. \
Reason:\n{}\n\nDo not repeat this call; use an allowed alternative or report \
that it can't be done here.",
truncate_for_halt(result),
),
Some(HardReject::Denied) => format!(
"Stopping: the `{tool}` call was denied and re-issued unchanged — re-asking \
will not change the answer. Reason:\n{}\n\nDo not repeat this call; take a \
different approach or report that it can't be done here.",
truncate_for_halt(result),
),
None => format!(
"Stopping: the `{tool}` call was retried {count} times with identical \
arguments and kept failing — repeating it will not help. Last error:\n{}\n\n\
{} Report this back instead of retrying.",
truncate_for_halt(result),
if recoverable {
"This looked recoverable at first, but the same call exhausted the \
extended transient-failure headroom."
} else {
"This looks unrecoverable in the current environment (e.g. a missing \
tool/dependency that cannot be installed here)."
},
),
});
}
if recoverable {
if self.consecutive_recoverable >= RECOVERABLE_NO_PROGRESS_FAILURE_THRESHOLD {
return Some(format!(
"Stopping: {} recoverable-looking tool failures happened in a row with no \
successful progress. Last error (from `{tool}`):\n{}\n\nThe turn is still \
bounded by the iteration/cost limits, but this many consecutive transient \
failures means the goal is not currently reachable. Report this back instead \
of retrying.",
self.consecutive_recoverable,
truncate_for_halt(result),
));
}
return None;
}
if self.consecutive >= NO_PROGRESS_FAILURE_THRESHOLD {
return Some(format!(
"Stopping: {} tool calls in a row failed with no progress. Last error (from \
`{tool}`):\n{}\n\nDifferent commands are all failing — the goal looks unreachable \
in this environment. Report this back instead of retrying.",
self.consecutive,
truncate_for_halt(result),
));
}
None
}
}
/// If the model emits the IDENTICAL assistant output (narrative text + the same
/// tool-call name/args) this many times in a row, it's stuck in a no-progress
/// narration loop — halt. Set low enough to bail early (the observed
/// degeneration repeated ~195×) but above any legitimate short retry.
pub(crate) const REPEAT_OUTPUT_THRESHOLD: u32 = 4;
/// Repeat-OUTPUT circuit breaker — distinct from [`RepeatFailureGuard`], which
/// only counts tool *failures* and resets on every success.
///
/// This catches the degenerate case where each iteration re-emits the SAME
/// narration + SAME tool call and the call nominally "succeeds" yet nothing
/// advances (e.g. the model narrating "now let me create the files…" and
/// re-issuing the same `run_code` forever). That loop is invisible to two
/// things people reach for first:
/// * `frequency_penalty` — per-generation only; each iteration is a fresh,
/// individually non-repetitive generation, so it has nothing to penalise
/// and no memory across turns.
/// * [`RepeatFailureGuard`] — resets on success, so a repeated *successful*
/// no-op never trips it.
///
/// Trips on `REPEAT_OUTPUT_THRESHOLD` consecutive identical signatures; a
/// different signature (real progress) resets the run, so interleaved varied
/// work never trips it.
#[derive(Default)]
pub(crate) struct RepeatOutputGuard {
last_hash: Option<u64>,
consecutive: u32,
}
impl RepeatOutputGuard {
pub(crate) fn new() -> Self {
Self::default()
}
/// Reset the streak — used when an iteration is a legitimately-repeating
/// poll/wait (see [`is_repeat_call_exempt`]) that should count as a distinct
/// action rather than a no-progress repeat.
pub(crate) fn reset(&mut self) {
self.last_hash = None;
self.consecutive = 0;
}
/// Record one iteration's output signature (assistant text + tool-call
/// name/args). Returns `Some(halt summary)` once the identical signature has
/// repeated [`REPEAT_OUTPUT_THRESHOLD`] times back-to-back.
pub(crate) fn record(&mut self, signature: &str) -> Option<String> {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
signature.hash(&mut hasher);
let h = hasher.finish();
if self.last_hash == Some(h) {
self.consecutive += 1;
} else {
self.last_hash = Some(h);
self.consecutive = 1;
}
if self.consecutive >= REPEAT_OUTPUT_THRESHOLD {
return Some(format!(
"Stopping: the last {} iterations produced the IDENTICAL response and tool call \
with no change — the run is stuck repeating the same step without making \
progress. Re-issuing it will not help. Summarise what (if anything) was actually \
accomplished and report that the task could not progress, or take a genuinely \
different approach.",
self.consecutive,
));
}
None
}
}
/// If the model emits the IDENTICAL set of tool calls (the same `(tool, args)`
/// batch) this many times in a row — regardless of whether each call
/// *succeeds* — it is spinning the same action with no new information. Set just
/// below [`REPEAT_OUTPUT_THRESHOLD`] so a verbatim call loop is caught a step
/// earlier than the broader narration+call loop, and low enough to bail before
/// the iteration cap burns the whole budget (#4088).
pub(crate) const REPEAT_CALL_THRESHOLD: u32 = 3;
/// Tools whose contract is to be re-invoked with identical arguments, so an
/// identical repeat is legitimate progress — not a no-progress loop. Today this
/// is `wait_subagent`, which polls a running async sub-agent and explicitly
/// tells the model to "call wait_subagent again" when a `timeout_secs` window
/// elapses while the sub-agent is still running. Without this exemption a task
/// that outlives two wait windows would have its third identical
/// `wait_subagent({task_id})` halted by the no-progress breakers before it could
/// collect the eventual result, and the parent would misreport a stuck turn
/// (Codex P1 on #4230). The iteration cap + cost budget still bound the wait.
pub(crate) fn is_repeat_call_exempt(tool: &str) -> bool {
matches!(tool, "wait_subagent")
}
/// Repeated-CALL circuit breaker — closes the gap between [`RepeatFailureGuard`]
/// (resets on every success, so a repeated *successful* no-op never trips it)
/// and [`RepeatOutputGuard`] (keys on the assistant narration TOO, so trivially
/// varied prose around the same call resets the streak).
///
/// This guard keys ONLY on the canonical `(tool, args)` batch — no narration,
/// independent of success — so `list_dir("/app")` issued verbatim N times in a
/// row trips it even when each call returns 200 and the model reworded its
/// reasoning each time. A genuinely different call (different path / query / id)
/// changes the signature and resets the streak, so real progress — including the
/// read → write → read pattern, where the write is a distinct intervening call —
/// never trips it.
///
/// The caller builds the signature from `serde_json::Value::to_string()` of each
/// call's arguments, which is key-sorted and whitespace-free in this tree (no
/// `preserve_order` feature), so the SAME call expressed with reordered JSON keys
/// still collapses to one signature and cannot evade the guard.
#[derive(Default)]
pub(crate) struct RepeatCallGuard {
last_hash: Option<u64>,
consecutive: u32,
}
impl RepeatCallGuard {
pub(crate) fn new() -> Self {
Self::default()
}
/// Reset the streak — used when an iteration is a legitimately-repeating
/// poll/wait (see [`is_repeat_call_exempt`]) that should count as a distinct
/// action rather than a no-progress repeat.
pub(crate) fn reset(&mut self) {
self.last_hash = None;
self.consecutive = 0;
}
/// Record one iteration's tool-call signature (the canonical `(tool, args)`
/// of every call in the assistant message, in order). Returns `Some(halt
/// summary)` once the identical batch has repeated [`REPEAT_CALL_THRESHOLD`]
/// times back-to-back; a different signature (real progress) resets the run.
pub(crate) fn record(&mut self, signature: &str) -> Option<String> {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
signature.hash(&mut hasher);
let h = hasher.finish();
if self.last_hash == Some(h) {
self.consecutive += 1;
} else {
self.last_hash = Some(h);
self.consecutive = 1;
}
if self.consecutive >= REPEAT_CALL_THRESHOLD {
return Some(format!(
"Stopping: the same tool call was issued {} times in a row with identical \
arguments and no new information — the run is stuck repeating one action \
without making progress. Re-issuing it will not help. Summarise what (if \
anything) was actually accomplished and report that the task could not \
progress, or take a genuinely different action (a different tool, different \
arguments, or hand back).",
self.consecutive,
));
}
None
}
}
/// Clamp the last-error text embedded in a circuit-breaker halt summary so a huge
/// tool error (already capped at 1MB upstream) can't blow up the agent's result.
pub(crate) fn truncate_for_halt(s: &str) -> String {
const MAX: usize = 600;
if s.chars().count() <= MAX {
return s.to_string();
}
let head: String = s.chars().take(MAX).collect();
format!("{head}\n… [truncated]")
}
/// Execute a single turn of the agent loop: send messages, parse tool calls,
/// execute tools, and loop until the LLM produces a final text response.
/// When `silent` is true, suppresses stdout (for channel use).
///
/// This is a thin wrapper around [`run_tool_call_loop`] with the per-agent
/// filter and extra-tool plumbing disabled — i.e. the LLM sees the entire
/// `tools_registry` unchanged. Used by legacy call sites and harness tests
/// that don't need agent-aware scoping.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn agent_turn(
provider: &dyn Provider,
history: &mut Vec<ChatMessage>,
tools_registry: &[Box<dyn Tool>],
provider_name: &str,
model: &str,
temperature: f64,
silent: bool,
multimodal_config: &crate::openhuman::config::MultimodalConfig,
multimodal_file_config: &crate::openhuman::config::MultimodalFileConfig,
max_tool_iterations: usize,
payload_summarizer: Option<&dyn PayloadSummarizer>,
) -> Result<String> {
let default_policy = DefaultToolPolicy;
run_tool_call_loop(
provider,
history,
tools_registry,
provider_name,
model,
temperature,
silent,
"channel",
multimodal_config,
multimodal_file_config,
max_tool_iterations,
None,
None,
&[],
None,
payload_summarizer,
&default_policy,
)
.await
}
/// Execute a single turn of the agent loop: send messages, parse tool calls,
/// execute tools, and loop until the LLM produces a final text response.
///
/// # Per-agent tool scoping
///
/// The last two parameters support per-agent tool filtering without
/// requiring callers to build a filtered copy of the (non-`Clone`able)
/// tool registry:
///
/// * `visible_tool_names` — optional whitelist of tool names that are
/// allowed to reach the LLM. When `Some(set)`, only tools whose
/// `name()` is present in the set contribute to the function-calling
/// schema and are eligible for execution; every other tool in the
/// registry is hidden from the model and rejected if the model
/// somehow emits a call for it. When `None`, no filtering is applied
/// and every tool in the combined registry is visible (the legacy
/// behaviour used by CLI/REPL and harness tests).
///
/// * `extra_tools` — per-turn synthesised tools to splice alongside the
/// persistent `tools_registry`. The agent-dispatch path uses this to
/// surface delegation tools (`research`, `plan`,
/// `delegate_to_integrations_agent`, …) that are synthesised fresh
/// per turn from the active agent's `subagents` field and the
/// current Composio integration list, and therefore are not
/// registered in the global startup-time registry.
///
/// The combined tool list seen by the LLM this turn is
/// `tools_registry.iter().chain(extra_tools.iter())`, further narrowed
/// by `visible_tool_names` when supplied.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_tool_call_loop(
provider: &dyn Provider,
history: &mut Vec<ChatMessage>,
tools_registry: &[Box<dyn Tool>],
provider_name: &str,
model: &str,
temperature: f64,
silent: bool,
// Retained in the harness signature (callers pass their channel) but no
// longer consumed here since the legacy CLI approval prompt was removed —
// approval now flows through the process-global `ApprovalGate`.
_channel_name: &str,
multimodal_config: &crate::openhuman::config::MultimodalConfig,
multimodal_file_config: &crate::openhuman::config::MultimodalFileConfig,
max_tool_iterations: usize,
on_delta: Option<tokio::sync::mpsc::Sender<String>>,
visible_tool_names: Option<&HashSet<String>>,
extra_tools: &[Box<dyn Tool>],
on_progress: Option<tokio::sync::mpsc::Sender<AgentProgress>>,
payload_summarizer: Option<&dyn PayloadSummarizer>,
tool_policy: &dyn ToolPolicy,
) -> Result<String> {
let max_iterations = if max_tool_iterations == 0 {
DEFAULT_MAX_TOOL_ITERATIONS
} else {
max_tool_iterations
};
// The agentic loop itself now lives in the shared turn engine; this
// function is a thin adapter that builds the channel/CLI tool source
// (registry + per-turn extras, visibility whitelist, pluggable policy)
// and hands off. The signature is retained verbatim so existing callers
// (the `agent.run_turn` bus handler, triage, the payload summarizer, and
// the harness test suite) are unaffected.
log::debug!(
"[tool-loop] Registry has {} tool(s), extra {} tool(s), filter={}",
tools_registry.len(),
extra_tools.len(),
visible_tool_names
.map(|s| format!("whitelist({})", s.len()))
.unwrap_or_else(|| "none".to_string()),
);
let mut tool_source = super::engine::RegistryToolSource::new(
tools_registry,
extra_tools,
visible_tool_names,
tool_policy,
payload_summarizer,
);
let progress = super::engine::TurnProgress::new(on_progress);
let mut observer = super::engine::NullObserver;
let checkpoint = super::engine::ErrorCheckpoint;
let parser = super::engine::DefaultParser;
super::engine::run_turn_engine(
provider,
history,
&mut tool_source,
&progress,
&mut observer,
&checkpoint,
&parser,
provider_name,
model,
temperature,
silent,
multimodal_config,
multimodal_file_config,
max_iterations,
AGENT_TURN_MAX_OUTPUT_TOKENS,
on_delta,
&[],
None,
None, // channel/CLI/triage loop: context guard + token-budget trim only
)
.await
.map(|outcome| outcome.text)
}
#[cfg(test)]
#[path = "tool_loop_tests.rs"]
mod tests;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -158,6 +158,7 @@ mod tests {
delegate_name: None,
agent_tier: AgentTier::Worker,
source: DefinitionSource::Builtin,
graph: Default::default(),
}
}
+14 -19
View File
@@ -1,29 +1,24 @@
//! Mid-turn stop hooks — policy-driven halt of an in-flight agent
//! turn.
//!
//! Distinct from [`super::harness::interrupt::InterruptFence`], which
//! handles user-driven cancellation (Ctrl+C / `/stop`). Stop hooks are
//! the policy lever: budget caps, rate limits, custom kill switches.
//! They run between iterations of the tool-call loop so a runaway
//! turn can be cut short before the next provider call rather than
//! after the fact.
//! Stop hooks are the policy lever: budget caps, rate limits, custom kill
//! switches. They run between iterations of the agent loop so a runaway turn can
//! be cut short before the next provider call rather than after the fact.
//! (User-driven cancellation — Ctrl+C / `/stop` — is handled separately by the
//! `tinyagents` steering/cancellation channel.)
//!
//! ## Wiring
//!
//! Hooks ride on a task-local rather than a parameter on
//! [`crate::openhuman::agent::harness::tool_loop::run_tool_call_loop`]
//! — that signature already takes 16 args and the function is invoked
//! from a dozen+ call sites. The task-local mirrors how
//! [`super::harness::fork_context::PARENT_CONTEXT`] and
//! [`super::harness::sandbox_context::CURRENT_AGENT_SANDBOX_MODE`] are
//! threaded.
//! Hooks ride on a task-local rather than a parameter threaded through the turn,
//! mirroring how [`super::harness::fork_context::PARENT_CONTEXT`] and
//! [`super::harness::sandbox_context::CURRENT_AGENT_SANDBOX_MODE`] are threaded.
//!
//! Callers register hooks via [`with_stop_hooks`] around their
//! [`Agent::run_single`] / `run_interactive` invocation; the loop
//! reads them via [`current_stop_hooks`] and fires them at the top of
//! each iteration. A hook returning [`StopDecision::Stop`] aborts the
//! loop with a [`StoppedByHookError`]-shaped `anyhow` error so the
//! caller can surface the reason to the user.
//! Callers register hooks via [`with_stop_hooks`] around their turn invocation.
//! The `tinyagents` adapter snapshots them via [`current_stop_hooks`] and
//! installs a `StopHookMiddleware`
//! ([`crate::openhuman::tinyagents::stop_hooks`]) that fires each hook after
//! every model call; a hook returning [`StopDecision::Stop`] pauses the run
//! gracefully (via the steering handle) before the next provider call.
//!
//! ## Built-in hooks
//!
+7 -1
View File
@@ -836,9 +836,15 @@ async fn turn_preserves_text_alongside_tool_calls() {
"Expected non-empty final response after mixed text+tool"
);
// The intermediate text should be in history
// The intermediate text should be preserved in history — either as a
// standalone assistant `Chat` or carried on the `AssistantToolCalls` turn
// that accompanied the tool call (the unified tinyagents representation
// keeps the preface text on the tool-call turn).
let has_intermediate = agent.history().iter().any(|msg| match msg {
ConversationMessage::Chat(c) => c.role == "assistant" && c.content.contains("Let me check"),
ConversationMessage::AssistantToolCalls { text, .. } => {
text.as_deref().is_some_and(|t| t.contains("Let me check"))
}
_ => false,
});
assert!(has_intermediate, "Intermediate text should be in history");
+13
View File
@@ -0,0 +1,13 @@
//! Turn graph for the `agent_memory` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
+1
View File
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,294 @@
//! Team-member execution as a conditional-routing `tinyagents` graph (#4249, B2).
//!
//! This is the `agent_teams` folder's `graph.rs` per the per-folder graph
//! convention: the folder's tinyagents graph definition (member-run state
//! machine) lives here; `runtime.rs` drives it.
//!
//! A teammate's live run is a small state machine: **execute** the worker
//! sub-agent, then route on its terminal outcome to **complete** (quality-gate +
//! idle) or **fail** (release + idle + record), joining at a single **done**
//! finish node. Historically this was a hand-rolled `match` in
//! [`super::runtime::drive_member`]; here it is a real `tinyagents`
//! [`CompiledGraph`] with command-routing:
//!
//! ```text
//! ┌─ complete ─┐
//! execute ──►┤ ├─► done (finish)
//! └─ fail ─────┘
//! ```
//!
//! The worker run and the two reconciliation effects are **injected** as
//! closures so the graph mechanics (conditional routing + the engine-error
//! propagation contract) are unit-testable with trivial stubs while production
//! passes the real `spawn_agent`/`wait_agents` + run-ledger writes.
//!
//! Error contract (preserved from the legacy `drive_member`): `run_worker`
//! returns `Err` only for engine-internal failures (spawn/wait) — those
//! propagate out of the graph so the caller releases the task and idles the
//! member. A worker that *ran* but did not complete is a normal `Failed`
//! outcome handled by `on_failed`, which returns `Ok`.
use std::future::Future;
use std::sync::Arc;
use tinyagents::graph::export::GraphTopology;
use tinyagents::graph::{
ClosureStateReducer, Command, CompiledGraph, GraphBuilder, NodeContext, NodeResult,
};
use crate::openhuman::tinyagents::observability::GraphTracingSink;
/// Lift an injected effect's `anyhow` error into the graph's error type so it
/// fails the run (and propagates back out via [`run_member_execution_graph`]).
fn graph_err(e: anyhow::Error) -> tinyagents::TinyAgentsError {
tinyagents::TinyAgentsError::Graph(e.to_string())
}
/// Terminal classification of a teammate worker run, produced by the `execute`
/// node and used to route to `complete` or `fail`.
pub(super) enum MemberOutcome {
/// The worker completed; `output` is its result summary (completion
/// evidence).
Completed { output: String },
/// The worker ran but did not complete (failed / cancelled / closed /
/// defensively, non-terminal); `reason` explains why.
Failed { reason: String },
}
/// Typed state threaded through the member graph: carries the routed payload
/// (worker output on the complete path, failure reason on the fail path) so the
/// terminal node can run the matching reconciliation.
#[derive(Clone, Default)]
struct MemberState {
payload: Option<String>,
}
/// Reducer update emitted by the member graph nodes.
enum MemberUpdate {
/// Store the routed payload (output or reason).
Payload(String),
/// Terminal node fired; no state change.
Noop,
}
/// Drive a team member's execution on the conditional-routing graph above.
///
/// Returns `Ok(())` once the member reached a reconciled terminal node, or `Err`
/// when `run_worker` (or a reconciliation closure) failed with an
/// engine-internal error — the caller maps that to release-task + idle-member.
pub(super) async fn run_member_execution_graph<W, WF, C, CF, F, FF>(
label: &str,
run_worker: W,
on_complete: C,
on_failed: F,
) -> anyhow::Result<()>
where
W: Fn() -> WF + Clone + Send + Sync + 'static,
WF: Future<Output = anyhow::Result<MemberOutcome>> + Send + 'static,
C: Fn(String) -> CF + Clone + Send + Sync + 'static,
CF: Future<Output = anyhow::Result<()>> + Send + 'static,
F: Fn(String) -> FF + Clone + Send + Sync + 'static,
FF: Future<Output = anyhow::Result<()>> + Send + 'static,
{
let graph = build_member_graph(run_worker, on_complete, on_failed)?
.with_event_sink(Arc::new(GraphTracingSink::new(label.to_string())));
tracing::debug!(
target: "orchestration",
label,
"[orchestration] driving team member execution on tinyagents graph"
);
graph
.run(MemberState::default())
.await
.map_err(|e| anyhow::anyhow!("member graph run failed: {e}"))?;
Ok(())
}
/// Build (but do not run) the member-execution `CompiledGraph`. Shared by
/// [`run_member_execution_graph`] and [`member_graph_topology`] so the graph's
/// structure has one definition.
fn build_member_graph<W, WF, C, CF, F, FF>(
run_worker: W,
on_complete: C,
on_failed: F,
) -> anyhow::Result<CompiledGraph<MemberState, MemberUpdate>>
where
W: Fn() -> WF + Clone + Send + Sync + 'static,
WF: Future<Output = anyhow::Result<MemberOutcome>> + Send + 'static,
C: Fn(String) -> CF + Clone + Send + Sync + 'static,
CF: Future<Output = anyhow::Result<()>> + Send + 'static,
F: Fn(String) -> FF + Clone + Send + Sync + 'static,
FF: Future<Output = anyhow::Result<()>> + Send + 'static,
{
let mut builder = GraphBuilder::<MemberState, MemberUpdate>::new().set_reducer(
ClosureStateReducer::new(|mut s: MemberState, u: MemberUpdate| {
if let MemberUpdate::Payload(p) = u {
s.payload = Some(p);
}
Ok(s)
}),
);
// `execute`: run the worker, classify its outcome, and route accordingly.
builder = builder.add_node("execute", move |_s: MemberState, _c: NodeContext| {
let run_worker = run_worker.clone();
async move {
match run_worker().await.map_err(graph_err)? {
MemberOutcome::Completed { output } => Ok(NodeResult::Command(
Command::default()
.with_update(MemberUpdate::Payload(output))
.with_goto(["complete"]),
)),
MemberOutcome::Failed { reason } => Ok(NodeResult::Command(
Command::default()
.with_update(MemberUpdate::Payload(reason))
.with_goto(["fail"]),
)),
}
}
});
// `complete`: quality-gate + idle via the injected reconciliation.
builder = builder.add_node("complete", move |s: MemberState, _c: NodeContext| {
let on_complete = on_complete.clone();
async move {
on_complete(s.payload.unwrap_or_default())
.await
.map_err(graph_err)?;
Ok(NodeResult::Update(MemberUpdate::Noop))
}
});
// `fail`: release + idle + record via the injected reconciliation.
builder = builder.add_node("fail", move |s: MemberState, _c: NodeContext| {
let on_failed = on_failed.clone();
async move {
on_failed(s.payload.unwrap_or_default())
.await
.map_err(graph_err)?;
Ok(NodeResult::Update(MemberUpdate::Noop))
}
});
let graph = builder
.add_node("done", |_s: MemberState, _c: NodeContext| async move {
Ok(NodeResult::Update(MemberUpdate::Noop))
})
.add_edge("complete", "done")
.add_edge("fail", "done")
.set_entry("execute")
.mark_command_routing("execute")
.set_finish("done")
.compile()
.map_err(|e| anyhow::anyhow!("member graph compile failed: {e}"))?;
Ok(graph)
}
/// Structure-only [`GraphTopology`] of the member-execution graph for debug /
/// inspection (issue #4249, Phase 4). Built with no-op stub closures — the
/// topology exposes only node names, edges, and routing, never closure bodies.
pub(crate) fn member_graph_topology() -> anyhow::Result<GraphTopology> {
let graph = build_member_graph(
|| async {
Ok(MemberOutcome::Completed {
output: String::new(),
})
},
|_: String| async { Ok(()) },
|_: String| async { Ok(()) },
)?;
Ok(graph.topology())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
#[tokio::test]
async fn completed_outcome_routes_to_complete() {
let completed = Arc::new(AtomicBool::new(false));
let failed = Arc::new(AtomicBool::new(false));
let c = completed.clone();
let f = failed.clone();
run_member_execution_graph(
"test:complete",
|| async {
Ok(MemberOutcome::Completed {
output: "ok".into(),
})
},
move |out| {
let c = c.clone();
async move {
assert_eq!(out, "ok");
c.store(true, Ordering::SeqCst);
Ok(())
}
},
move |_reason| {
let f = f.clone();
async move {
f.store(true, Ordering::SeqCst);
Ok(())
}
},
)
.await
.expect("graph runs");
assert!(completed.load(Ordering::SeqCst), "complete path ran");
assert!(!failed.load(Ordering::SeqCst), "fail path did not run");
}
#[tokio::test]
async fn failed_outcome_routes_to_fail() {
let completed = Arc::new(AtomicBool::new(false));
let failed = Arc::new(AtomicBool::new(false));
let c = completed.clone();
let f = failed.clone();
run_member_execution_graph(
"test:fail",
|| async {
Ok(MemberOutcome::Failed {
reason: "boom".into(),
})
},
move |_out| {
let c = c.clone();
async move {
c.store(true, Ordering::SeqCst);
Ok(())
}
},
move |reason| {
let f = f.clone();
async move {
assert_eq!(reason, "boom");
f.store(true, Ordering::SeqCst);
Ok(())
}
},
)
.await
.expect("graph runs");
assert!(failed.load(Ordering::SeqCst), "fail path ran");
assert!(
!completed.load(Ordering::SeqCst),
"complete path did not run"
);
}
#[tokio::test]
async fn engine_error_from_worker_propagates() {
let result = run_member_execution_graph(
"test:err",
|| async { Err(anyhow::anyhow!("spawn failed")) },
|_out| async { Ok(()) },
|_reason| async { Ok(()) },
)
.await;
assert!(result.is_err(), "worker engine error propagates out");
}
}
@@ -22,6 +22,8 @@
//! Namespace note: `agent_team` is distinct from the existing `team` domain,
//! which manages backend org/team membership.
mod graph;
pub(crate) use graph::member_graph_topology;
pub mod ops;
pub mod runtime;
mod schemas;
@@ -237,90 +237,150 @@ async fn drive_member(
let prompt = build_member_prompt(task, &delivered);
let session = AgentOrchestrationSession::new(format!("team-{team_id}-{member_id}"));
let resp = session
.spawn_agent(SpawnAgentRequest {
agent_id: agent_id.to_string(),
prompt,
model: model_override,
metadata: [
("teamId".to_string(), team_id.to_string()),
("memberId".to_string(), member_id.to_string()),
("taskId".to_string(), task.id.clone()),
("teamRunId".to_string(), run_id.to_string()),
]
.into_iter()
.collect(),
..Default::default()
})
.await
.map_err(|e| anyhow!("spawn teammate worker failed: {e}"))?;
let wait = session
.wait_agents(WaitAgentOptions {
orchestration_ids: vec![resp.orchestration_id.clone()],
timeout_ms: None,
})
.await
.map_err(|e| anyhow!("wait teammate worker failed: {e}"))?;
// ── Worker node effect: spawn the teammate sub-agent, wait for it, and
// classify the terminal outcome. Returns `Err` only for engine-internal
// spawn/wait failures (the caller releases + idles).
let run_worker = {
let session = session.clone();
let agent_id = agent_id.to_string();
let team_id = team_id.to_string();
let member_id = member_id.to_string();
let task_id = task.id.clone();
let run_id = run_id.to_string();
move || {
let session = session.clone();
let agent_id = agent_id.clone();
let prompt = prompt.clone();
let model = model_override.clone();
let team_id = team_id.clone();
let member_id = member_id.clone();
let task_id = task_id.clone();
let run_id = run_id.clone();
async move {
let resp = session
.spawn_agent(SpawnAgentRequest {
agent_id,
prompt,
model,
metadata: [
("teamId".to_string(), team_id),
("memberId".to_string(), member_id),
("taskId".to_string(), task_id),
("teamRunId".to_string(), run_id),
]
.into_iter()
.collect(),
..Default::default()
})
.await
.map_err(|e| anyhow!("spawn teammate worker failed: {e}"))?;
let snapshot = wait
.agents
.into_iter()
.find(|a| a.orchestration_id == resp.orchestration_id)
.ok_or_else(|| anyhow!("worker snapshot missing after wait"))?;
let wait = session
.wait_agents(WaitAgentOptions {
orchestration_ids: vec![resp.orchestration_id.clone()],
timeout_ms: None,
})
.await
.map_err(|e| anyhow!("wait teammate worker failed: {e}"))?;
match snapshot.status {
AgentStatus::Completed => {
let output = snapshot.result_summary.unwrap_or_default();
let evidence = if output.trim().is_empty() {
Vec::new()
} else {
vec![format!(
"run:{run_id} — {}",
truncate_chars(output.trim(), EVIDENCE_MAX_CHARS)
)]
};
// The teammate's own output is the completion evidence, so the gate
// does not additionally require evidence; it still enforces the
// dependency / claimant invariants.
let outcome = run_ledger::complete_agent_team_task(
config, team_id, &task.id, member_id, &evidence, false,
)?;
log::debug!(
target: LOG_TARGET,
"[agent_team_runtime] drive.completed team={team_id} member={member_id} task={} outcome={outcome:?}",
task.id
);
run_ledger::mark_agent_team_member_idle(config, team_id, member_id)?;
Ok(())
let snapshot = wait
.agents
.into_iter()
.find(|a| a.orchestration_id == resp.orchestration_id)
.ok_or_else(|| anyhow!("worker snapshot missing after wait"))?;
Ok(match snapshot.status {
AgentStatus::Completed => super::graph::MemberOutcome::Completed {
output: snapshot.result_summary.unwrap_or_default(),
},
AgentStatus::Failed | AgentStatus::Cancelled | AgentStatus::Closed => {
super::graph::MemberOutcome::Failed {
reason: snapshot
.error
.unwrap_or_else(|| "worker ended without completing".to_string()),
}
}
// `wait_agents` with no timeout only returns on terminal
// status, so this is purely defensive — treat as a failure.
other => super::graph::MemberOutcome::Failed {
reason: format!("worker returned non-terminal status {other:?}"),
},
})
}
}
AgentStatus::Failed | AgentStatus::Cancelled | AgentStatus::Closed => {
let reason = snapshot
.error
.unwrap_or_else(|| "worker ended without completing".to_string());
log::warn!(
target: LOG_TARGET,
"[agent_team_runtime] drive.worker_failed team={team_id} member={member_id} task={} reason={reason}",
task.id
);
run_ledger::release_agent_team_task(config, team_id, &task.id)?;
run_ledger::mark_agent_team_member_idle(config, team_id, member_id)?;
record_failure_event(config, team_id, member_id, &task.id, &reason);
Ok(())
};
// ── Complete node effect: the teammate's own output is the completion
// evidence (the gate enforces dependency / claimant invariants but not
// additional evidence), then idle the member.
let on_complete = {
let config = config.clone();
let team_id = team_id.to_string();
let member_id = member_id.to_string();
let task_id = task.id.clone();
let run_id = run_id.to_string();
move |output: String| {
let config = config.clone();
let team_id = team_id.clone();
let member_id = member_id.clone();
let task_id = task_id.clone();
let run_id = run_id.clone();
async move {
let evidence = if output.trim().is_empty() {
Vec::new()
} else {
vec![format!(
"run:{run_id} — {}",
truncate_chars(output.trim(), EVIDENCE_MAX_CHARS)
)]
};
let outcome = run_ledger::complete_agent_team_task(
&config, &team_id, &task_id, &member_id, &evidence, false,
)?;
log::debug!(
target: LOG_TARGET,
"[agent_team_runtime] drive.completed team={team_id} member={member_id} task={task_id} outcome={outcome:?}"
);
run_ledger::mark_agent_team_member_idle(&config, &team_id, &member_id)?;
Ok(())
}
}
other => {
// `wait_agents` with no timeout only returns on terminal status, so
// this is purely defensive.
log::warn!(
target: LOG_TARGET,
"[agent_team_runtime] drive.non_terminal team={team_id} member={member_id} task={} status={other:?}",
task.id
);
run_ledger::release_agent_team_task(config, team_id, &task.id)?;
run_ledger::mark_agent_team_member_idle(config, team_id, member_id)?;
Ok(())
};
// ── Fail node effect: release the task (so it is reclaimable), idle the
// member, and record a failure event. Returns `Ok` (a ran-but-failed worker
// is a normal terminal outcome, not an engine error).
let on_failed = {
let config = config.clone();
let team_id = team_id.to_string();
let member_id = member_id.to_string();
let task_id = task.id.clone();
move |reason: String| {
let config = config.clone();
let team_id = team_id.clone();
let member_id = member_id.clone();
let task_id = task_id.clone();
async move {
log::warn!(
target: LOG_TARGET,
"[agent_team_runtime] drive.worker_failed team={team_id} member={member_id} task={task_id} reason={reason}"
);
run_ledger::release_agent_team_task(&config, &team_id, &task_id)?;
run_ledger::mark_agent_team_member_idle(&config, &team_id, &member_id)?;
record_failure_event(&config, &team_id, &member_id, &task_id, &reason);
Ok(())
}
}
}
};
super::graph::run_member_execution_graph(
&format!("team:{team_id}:{member_id}"),
run_worker,
on_complete,
on_failed,
)
.await
}
/// Pick the member's next claimable task: the first (by order) task that is
@@ -0,0 +1,165 @@
//! Production wiring for the multi-stage sub-agent delegation graph (issue
//! #4249, Phase 3).
//!
//! [`tinyagents::delegation::run_delegation`](crate::openhuman::tinyagents::delegation::run_delegation)
//! is the durable plan→execute⇄review→finalize state machine, but it takes an
//! *injected* per-stage worker so its orchestration mechanics can be unit-tested
//! with a mock. This module supplies the **production** worker: every stage runs
//! through [`run_subagent`] (the same dispatch path `spawn_subagent` uses), and
//! the run is made durable/resumable by checkpointing the typed
//! [`DelegationState`] to the openhuman session DB through
//! [`SqlRunLedgerCheckpointer`].
//!
//! Layering: the delegation *graph* lives in the `tinyagents` adapter seam; this
//! production glue lives in `agent_orchestration`, which already depends on both
//! the seam and `subagent_runner` (so the seam stays free of orchestration deps).
use std::sync::Arc;
use crate::openhuman::agent::harness::definition::AgentDefinition;
use crate::openhuman::agent::harness::fork_context::{current_parent, with_parent_context};
use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions};
use crate::openhuman::agent_orchestration::parent_context::build_root_parent;
use crate::openhuman::config::Config;
use crate::openhuman::tinyagents::delegation::{
run_delegation, DelegationConfig, DelegationStage, DelegationStageOutput, DelegationState,
};
use crate::openhuman::tinyagents::SqlRunLedgerCheckpointer;
use tinyagents::graph::checkpoint::Checkpointer;
use tinyagents::CancellationToken;
const LOG_TARGET: &str = "agent_orchestration::delegation";
/// Run the durable plan→execute⇄review→finalize delegation graph for
/// `definition` against `task_prompt`, dispatching every stage to
/// [`run_subagent`] and checkpointing the typed state to the session DB so the
/// run is resumable. Returns the terminal [`DelegationState`] (its
/// `final_output` holds the synthesized answer).
///
/// Reuses the caller's enclosing agent turn when one is present (e.g. the
/// `delegate` tool). When none is — a controller/background caller — a root
/// parent is built so the nested `run_subagent` calls still resolve a provider,
/// tool registry, memory, and model (mirroring the workflow engine + team
/// runtime).
pub async fn run_subagent_delegation(
config: Arc<Config>,
definition: AgentDefinition,
task_prompt: String,
max_revisions: usize,
) -> Result<DelegationState, String> {
let thread_id = format!("delegrun-{}", uuid::Uuid::new_v4());
let checkpointer: Arc<dyn Checkpointer<DelegationState>> =
Arc::new(SqlRunLedgerCheckpointer::new(config.clone()));
tracing::info!(
target: LOG_TARGET,
agent_id = %definition.id,
thread_id = %thread_id,
max_revisions,
"[delegation] starting durable sub-agent delegation"
);
let run = async move {
// Re-entrant per-stage worker: clones its captures each call so the graph
// node handler stays `Fn` while each stage dispatches a fresh sub-agent.
let run_stage = move |stage: DelegationStage, state: DelegationState| {
let definition = definition.clone();
let task = task_prompt.clone();
async move {
let prompt = build_stage_prompt(stage, &task, &state);
match run_subagent(&definition, &prompt, delegation_subagent_options()).await {
Ok(outcome) => {
let approved = matches!(stage, DelegationStage::Review)
&& review_approves(&outcome.output);
Ok(DelegationStageOutput {
text: outcome.output,
approved,
})
}
Err(e) => Err(format!("delegation stage {stage:?} failed: {e}")),
}
}
};
let delegation_config = DelegationConfig {
max_revisions,
checkpointer: Some(checkpointer),
thread_id: Some(thread_id),
cancel: CancellationToken::new(),
};
run_delegation(delegation_config, run_stage).await
};
if current_parent().is_some() {
run.await
} else {
let parent = build_root_parent(&config, "delegation_engine", "delegation", "delegation")
.await
.map_err(|e| format!("delegation: failed to build root parent: {e}"))?;
with_parent_context(parent, run).await
}
}
/// Per-stage prompt builder: each stage sees the task plus the accumulated state
/// (the plan for `execute`, the latest result for `review`, and the latest
/// reviewer feedback when re-executing after a revision request).
fn build_stage_prompt(stage: DelegationStage, task: &str, state: &DelegationState) -> String {
match stage {
DelegationStage::Plan => format!(
"Produce a short, concrete, numbered plan to accomplish the task below. \
Reply with the plan only.\n\n[Task]\n{task}"
),
DelegationStage::Execute => {
let plan = state.plan.as_deref().unwrap_or("(no plan produced)");
let feedback = state
.reviews
.last()
.map(|r| format!("\n\n[Reviewer feedback to address]\n{r}"))
.unwrap_or_default();
format!(
"Carry out the plan below for the task and return the completed result.\n\n\
[Task]\n{task}\n\n[Plan]\n{plan}{feedback}"
)
}
DelegationStage::Review => {
let result = state
.executions
.last()
.map(String::as_str)
.unwrap_or("(no execution produced)");
format!(
"Review the result below against the task. If it fully and correctly \
accomplishes the task, reply with `APPROVE` on the first line. Otherwise \
reply with `REVISE` on the first line followed by specific, actionable \
feedback.\n\n[Task]\n{task}\n\n[Result]\n{result}"
)
}
}
}
/// A review stage approves when its first line begins with `APPROVE`.
fn review_approves(output: &str) -> bool {
output
.lines()
.next()
.map(|l| l.trim().to_ascii_uppercase())
.map(|l| l.starts_with("APPROVE"))
.unwrap_or(false)
}
/// Default sub-agent options for a delegation stage — a fresh UUID task id per
/// call (so retries/revisions don't collide), everything else inherited.
fn delegation_subagent_options() -> SubagentRunOptions {
SubagentRunOptions {
skill_filter_override: None,
toolkit_override: None,
context: None,
model_override: None,
task_id: None,
worker_thread_id: None,
initial_history: None,
checkpoint_dir: None,
worktree_action_dir: None,
run_queue: None,
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod agent_teams;
pub mod background_completions;
pub mod background_delivery;
pub mod command_center;
pub mod delegation;
mod ops;
pub(crate) mod parent_context;
pub mod run_ledger_finalize;
+43 -7
View File
@@ -13,7 +13,7 @@ use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use tokio::time::{sleep, Duration};
use tokio::time::Duration;
#[derive(Default)]
struct NoopMemory;
@@ -170,12 +170,33 @@ impl Provider for CodingQuestionProvider {
}
}
#[derive(Default)]
/// Number of parallel sub-agents the parallel-coding test spawns. The provider's
/// synchronization barrier is sized to this so the peak-concurrency assertion is
/// deterministic regardless of scheduler/load.
const PARALLEL_CHILDREN: usize = 3;
struct ParallelState {
calls: AtomicUsize,
active: AtomicUsize,
max_active: AtomicUsize,
prompts: Mutex<Vec<String>>,
/// Rendezvous point: every child parks here (yielding its worker thread)
/// until all `PARALLEL_CHILDREN` are concurrently inside `chat`, so
/// `max_active` deterministically reaches the peak instead of depending on
/// whether the brief provider calls happen to overlap in wall-clock time.
gate: tokio::sync::Barrier,
}
impl Default for ParallelState {
fn default() -> Self {
Self {
calls: AtomicUsize::new(0),
active: AtomicUsize::new(0),
max_active: AtomicUsize::new(0),
prompts: Mutex::new(Vec::new()),
gate: tokio::sync::Barrier::new(PARALLEL_CHILDREN),
}
}
}
#[derive(Clone, Default)]
@@ -240,7 +261,11 @@ impl Provider for ParallelCodingProvider {
self.state.calls.fetch_add(1, Ordering::SeqCst);
let current = self.state.active.fetch_add(1, Ordering::SeqCst) + 1;
self.record_peak(current);
sleep(Duration::from_millis(35)).await;
// Park until all children have entered `chat` (or a generous timeout, so
// an unexpected missing child fails the assertion fast rather than
// hanging). Once released, every child was concurrently active, so the
// recorded peak equals `PARALLEL_CHILDREN`.
let _ = tokio::time::timeout(Duration::from_secs(10), self.state.gate.wait()).await;
let flattened = request
.messages
@@ -303,10 +328,15 @@ async fn e2e_orchestrator_answers_coding_agent_question_and_resumes_child() {
.await
.expect("spawn coding agent");
// These waits spawn a *real* builtin (`code_executor`) sub-agent on the
// detached executor, which builds the full agent (prompt assembly, tool
// resolution, registry) before the mock provider returns — ~2.7s per child.
// The wait budget must clear that with CI headroom; a tight 2s expires first
// and reports the child as `Running`.
let first_wait = session
.wait_agents(WaitAgentOptions {
orchestration_ids: vec![first.orchestration_id.clone()],
timeout_ms: Some(2_000),
timeout_ms: Some(15_000),
})
.await
.expect("wait first child");
@@ -347,7 +377,7 @@ async fn e2e_orchestrator_answers_coding_agent_question_and_resumes_child() {
let final_wait = session
.wait_agents(WaitAgentOptions {
orchestration_ids: vec![follow_up.orchestration_id.clone()],
timeout_ms: Some(2_000),
timeout_ms: Some(15_000),
})
.await
.expect("wait follow-up");
@@ -369,7 +399,13 @@ async fn e2e_orchestrator_answers_coding_agent_question_and_resumes_child() {
assert!(prompts[1].contains("ORCH_ANSWER_USE_RPC"));
}
#[tokio::test]
// Multi-thread runtime: this test asserts the three detached sub-agents run
// *concurrently* (`max_active >= 2`). Each child does a CPU-bound builtin-agent
// build before its (mock) provider call; on a single-threaded runtime those
// builds serialize, so the brief provider calls never overlap and the peak-
// concurrency assertion flakes under load. Real worker threads let the builds —
// and therefore the provider calls — actually overlap.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn e2e_orchestrator_waits_for_multiple_parallel_coding_subagents() {
AgentDefinitionRegistry::init_global_builtins().unwrap();
let provider = ParallelCodingProvider::default();
@@ -413,7 +449,7 @@ async fn e2e_orchestrator_waits_for_multiple_parallel_coding_subagents() {
let waited = session
.wait_agents(WaitAgentOptions {
orchestration_ids: spawned,
timeout_ms: Some(2_000),
timeout_ms: Some(15_000),
})
.await
.expect("wait parallel children");
@@ -17,6 +17,18 @@
//! and swept on `register` only once the table passes a soft cap, so it can't
//! grow unbounded if a parent never waits (the Codex "spawn-slot leak" failure
//! mode — openai/codex#18335).
//!
//! ## Typed lifecycle ledger (issue #4249)
//!
//! Alongside the executor plumbing (abort handle + steering queue + watch
//! status), every detached sub-agent is also recorded in a process-wide
//! [`tinyagents` orchestration `TaskStore`](crate::openhuman::tinyagents::orchestration)
//! as an `OrchestrationTaskKind::SubAgent` task. `register` inserts it
//! (`Pending` → `Running`) and spawns a watcher that mirrors the child's
//! terminal status into the store (`Completed`/`Failed`/`Awaiting`); the cancel
//! paths record `Cancelled`. This gives a typed, queryable lifecycle
//! (`task_records`) without disturbing the watch/abort/steer machinery the store
//! does not cover.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
@@ -27,6 +39,73 @@ use tokio::sync::watch;
use tokio::task::AbortHandle;
use crate::openhuman::agent::harness::run_queue::{QueueMode, QueuedMessage, RunQueue};
use crate::openhuman::tinyagents::orchestration::{
InMemoryTaskStore, OrchestrationTaskFilter, OrchestrationTaskKind, OrchestrationTaskRecord,
OrchestrationTaskResult, OrchestrationTaskSpec, TaskStore,
};
use tinyagents::harness::ids::TaskId;
/// Process-wide typed lifecycle ledger for detached sub-agents (issue #4249).
fn task_store() -> &'static InMemoryTaskStore {
static STORE: OnceLock<InMemoryTaskStore> = OnceLock::new();
STORE.get_or_init(InMemoryTaskStore::new)
}
/// Record a freshly-spawned sub-agent in the store (`Pending` → `Running`).
/// Insert errors (e.g. a re-used task id across tests) are intentionally ignored.
fn record_spawned(task_id: &str, agent_id: &str, parent_session: &str) {
let store = task_store();
let spec = OrchestrationTaskSpec::new(
task_id.to_string(),
OrchestrationTaskKind::SubAgent {
agent: agent_id.to_string(),
},
)
.with_metadata("parentSession", parent_session.to_string());
let _ = store.insert(spec);
let _ = store.mark_running(&TaskId::new(task_id));
}
/// Mirror a child's published [`SubagentStatus`] into the typed store. Transition
/// errors (already terminal / cancelled) are ignored — first writer wins.
fn record_status(task_id: &str, status: &SubagentStatus) {
let store = task_store();
let id = TaskId::new(task_id);
match status {
SubagentStatus::Completed { output, .. } => {
let _ = store.complete(&id, OrchestrationTaskResult::text(output.clone()));
}
SubagentStatus::Failed { error } => {
let _ = store.fail(&id, error.clone());
}
SubagentStatus::AwaitingUser { .. } => {
let _ = store.mark_awaiting(&id);
}
SubagentStatus::Running => {}
}
}
/// Record a cancellation (`CancelRequested` → `Cancelled`) for `task_id`.
fn record_cancelled(task_id: &str) {
let store = task_store();
let id = TaskId::new(task_id);
let _ = store.request_cancel(&id);
let _ = store.mark_cancelled(&id);
}
/// Snapshot the typed lifecycle records, optionally scoped to a `parent_session`.
/// Backs typed status surfaces without touching the live registry's executor
/// plumbing.
pub fn task_records(parent_session: Option<&str>) -> Vec<OrchestrationTaskRecord> {
let all = task_store().list(OrchestrationTaskFilter::default());
match parent_session {
Some(ps) => all
.into_iter()
.filter(|r| r.spec.metadata.get("parentSession").map(String::as_str) == Some(ps))
.collect(),
None => all,
}
}
/// Terminal/transient state of a running async sub-agent, published by the
/// spawner's background task and observed by `wait_subagent`.
@@ -110,6 +189,12 @@ pub fn register(
abort: AbortHandle,
status: watch::Receiver<SubagentStatus>,
) {
// Typed lifecycle ledger: record the spawn and mirror the child's terminal
// status into the store via a lightweight watcher (issue #4249). Done before
// the entry is moved into the map so the metadata is still in scope.
record_spawned(&task_id, &agent_id, &parent_session);
spawn_status_watcher(task_id.clone(), status.clone());
let entry = RunningSubagentEntry {
agent_id,
parent_session,
@@ -135,6 +220,30 @@ pub fn register(
);
}
/// Watch a child's status channel and mirror the first terminal status into the
/// typed lifecycle store. A dropped sender (aborted/panicked task) without a
/// terminal status is recorded as a failure, matching [`wait`].
fn spawn_status_watcher(task_id: String, mut status: watch::Receiver<SubagentStatus>) {
tokio::spawn(async move {
loop {
let snapshot = status.borrow_and_update().clone();
if snapshot.is_terminal() {
record_status(&task_id, &snapshot);
break;
}
if status.changed().await.is_err() {
record_status(
&task_id,
&SubagentStatus::Failed {
error: "sub-agent task ended without reporting a result".to_string(),
},
);
break;
}
}
});
}
/// Resolve a durable `subagent_session_id` to the currently-running transient
/// `task_id`, enforcing parent-session ownership.
pub fn task_id_for_session(
@@ -357,6 +466,7 @@ pub fn cancel_by_task(task_id: &str) -> Option<CancelledSubagent> {
let mut map = registry().lock().expect("running_subagents mutex poisoned");
let entry = map.remove(task_id)?;
entry.abort.abort();
record_cancelled(task_id);
log::debug!(
"[running_subagents] cancel_by_task task_id={} agent_id={} parent_thread_id={:?} live_entries={}",
task_id,
@@ -389,6 +499,7 @@ pub fn close(task_id: &str, parent_session: &str) -> bool {
Some(entry) if entry.parent_session == parent_session => {
entry.abort.abort();
map.remove(task_id);
record_cancelled(task_id);
true
}
_ => false,
@@ -409,6 +520,7 @@ pub fn cancel_for_thread(thread_id: &str) -> usize {
for id in &to_cancel {
if let Some(entry) = map.remove(id) {
entry.abort.abort();
record_cancelled(id);
}
}
let count = to_cancel.len();
@@ -432,8 +544,9 @@ pub fn cancel_all() -> Vec<String> {
let count = map.len();
let mut thread_ids: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for (_, entry) in map.drain() {
for (task_id, entry) in map.drain() {
entry.abort.abort();
record_cancelled(&task_id);
if let Some(thread_id) = entry.parent_thread_id {
if seen.insert(thread_id.clone()) {
thread_ids.push(thread_id);
@@ -465,6 +578,7 @@ fn now_ms() -> u64 {
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::tinyagents::orchestration::OrchestrationTaskStatus;
use std::sync::MutexGuard;
/// Serializes every test that touches the global [`REGISTRY`]. We reuse the
@@ -516,6 +630,55 @@ mod tests {
tx
}
#[tokio::test]
async fn task_store_records_spawn_complete_and_cancel() {
let _guard = test_guard();
// Spawn → the ledger sees a running SubAgent task scoped to the parent.
let tx = register_test("task-ledger-1", "ledger-parent", RunQueue::new());
let running = task_records(Some("ledger-parent"));
assert!(
running
.iter()
.any(|r| r.spec.task_id.as_str() == "task-ledger-1"
&& r.status == OrchestrationTaskStatus::Running),
"spawned sub-agent is recorded Running: {running:?}"
);
// Publish a terminal status → the watcher mirrors Completed into the store.
tx.send(SubagentStatus::Completed {
output: "done".into(),
iterations: 2,
})
.unwrap();
// Let the watcher task observe the change.
for _ in 0..50 {
tokio::task::yield_now().await;
if task_records(None)
.iter()
.any(|r| r.spec.task_id.as_str() == "task-ledger-1" && r.is_terminal())
{
break;
}
}
let after = task_records(None);
let rec = after
.iter()
.find(|r| r.spec.task_id.as_str() == "task-ledger-1")
.expect("ledger record present");
assert_eq!(rec.status, OrchestrationTaskStatus::Completed);
// A second sub-agent that gets cancelled is recorded Cancelled.
let _tx2 = register_test("task-ledger-2", "ledger-parent", RunQueue::new());
assert!(cancel_by_task("task-ledger-2").is_some());
let cancelled = task_records(None)
.into_iter()
.find(|r| r.spec.task_id.as_str() == "task-ledger-2")
.expect("cancelled record present");
assert_eq!(cancelled.status, OrchestrationTaskStatus::Cancelled);
prune("task-ledger-1");
}
#[tokio::test]
async fn task_id_for_session_enforces_parent_ownership() {
let _guard = test_guard();
@@ -8,6 +8,8 @@ mod awaiting_user;
mod close_subagent;
#[path = "tools/continue_subagent.rs"]
mod continue_subagent;
#[path = "tools/delegate_graph.rs"]
mod delegate_graph;
#[path = "tools/dispatch.rs"]
mod dispatch;
#[path = "tools/list_subagents.rs"]
@@ -42,6 +44,7 @@ pub use agent_prepare_context::{
pub use archetype_delegation::ArchetypeDelegationTool;
pub use close_subagent::CloseSubagentTool;
pub use continue_subagent::ContinueSubagentTool;
pub use delegate_graph::DelegateGraphTool;
pub use list_subagents::ListSubagentsTool;
pub use skill_delegation::{SkillDelegationTool, INTEGRATIONS_DELEGATE_TOOL_NAME};
pub use spawn_async_subagent::SpawnAsyncSubagentTool;
@@ -0,0 +1,164 @@
//! Tool: `delegate` — run a multi-stage, durable sub-agent delegation.
//!
//! Where `spawn_subagent` hands a sub-task to a single sub-agent turn, `delegate`
//! drives the durable plan→execute⇄review→finalize graph
//! ([`agent_orchestration::delegation::run_subagent_delegation`](crate::openhuman::agent_orchestration::delegation::run_subagent_delegation)):
//! the chosen agent first plans, then executes, then reviews its own work and
//! revises up to `max_revisions` times before finalizing. The graph checkpoints
//! its typed state to the session DB, so a crashed or paused run is resumable.
//!
//! Use it for non-trivial sub-tasks that benefit from a self-review/revision
//! loop; use `spawn_subagent` for a single focused hand-off.
use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
use crate::openhuman::agent_orchestration::delegation::run_subagent_delegation;
use crate::openhuman::config::Config;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
/// Default reviewer-requested revision budget when the caller omits it.
const DEFAULT_MAX_REVISIONS: usize = 2;
/// Hard ceiling so a caller can't request an unbounded execute⇄review loop.
const MAX_MAX_REVISIONS: usize = 5;
/// Runs the durable multi-stage delegation graph for a chosen sub-agent.
pub struct DelegateGraphTool;
impl Default for DelegateGraphTool {
fn default() -> Self {
Self::new()
}
}
impl DelegateGraphTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Tool for DelegateGraphTool {
fn name(&self) -> &str {
// Distinct from the config-driven `delegate` tool (`DelegateTool`, added
// when `[agents]` are configured) so the two don't collide in the
// first-match tool registry — a shared `delegate` name made whichever
// registered first shadow the other. This is the graph delegation path.
"delegate_graph"
}
fn description(&self) -> &str {
"Delegate a non-trivial sub-task to a specialised sub-agent that PLANS, \
EXECUTES, then REVIEWS and revises its own work before returning a final \
result (a durable planexecutereviewfinalize loop). Prefer this over \
`spawn_subagent` when the sub-task benefits from a self-review/revision \
pass. Provide `agent_id` and a complete, self-contained `task` (the \
sub-agent has no memory of this conversation)."
}
fn parameters_schema(&self) -> serde_json::Value {
let agent_ids: Vec<String> = AgentDefinitionRegistry::global()
.map(|reg| reg.list().iter().map(|d| d.id.clone()).collect())
.unwrap_or_default();
let agent_id_schema = if agent_ids.is_empty() {
json!({
"type": "string",
"description": "Sub-agent id (e.g. code_executor, researcher, critic)."
})
} else {
json!({
"type": "string",
"enum": agent_ids,
"description": "Sub-agent id from the registry."
})
};
json!({
"type": "object",
"required": ["agent_id", "task"],
"properties": {
"agent_id": agent_id_schema,
"task": {
"type": "string",
"description": "Complete, self-contained description of the task. Include all context the sub-agent needs — it cannot see this conversation."
},
"max_revisions": {
"type": "integer",
"minimum": 0,
"maximum": MAX_MAX_REVISIONS,
"description": "Maximum reviewer-requested revisions before finalizing (default 2)."
}
}
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Execute
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let agent_id = match args.get("agent_id").and_then(|v| v.as_str()) {
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => return Ok(ToolResult::error("delegate: `agent_id` is required.")),
};
let task = match args.get("task").and_then(|v| v.as_str()) {
Some(s) if !s.trim().is_empty() => s.to_string(),
_ => return Ok(ToolResult::error("delegate: `task` is required.")),
};
let max_revisions = args
.get("max_revisions")
.and_then(|v| v.as_u64())
.map(|n| (n as usize).min(MAX_MAX_REVISIONS))
.unwrap_or(DEFAULT_MAX_REVISIONS);
let registry = match AgentDefinitionRegistry::global() {
Some(reg) => reg,
None => {
return Ok(ToolResult::error(
"delegate: agent definition registry not initialized.",
))
}
};
let definition = match registry.get(&agent_id) {
Some(def) => def.clone(),
None => {
return Ok(ToolResult::error(format!(
"delegate: agent definition '{agent_id}' not found in registry."
)))
}
};
let config = match Config::load_or_init().await {
Ok(cfg) => Arc::new(cfg),
Err(e) => {
return Ok(ToolResult::error(format!(
"delegate: failed to load config: {e}"
)))
}
};
match run_subagent_delegation(config, definition, task, max_revisions).await {
Ok(state) => {
let final_output = state
.final_output
.unwrap_or_else(|| "(delegation produced no final output)".to_string());
let note = if state.cancelled {
" (cancelled)"
} else if state.revisions > 0 {
" (after revision)"
} else {
""
};
Ok(ToolResult::success(format!(
"[Delegated to {agent_id}{note}, {} review pass(es)]\n{final_output}",
state.reviews.len()
)))
}
Err(e) => Ok(ToolResult::error(format!(
"delegate failed for '{agent_id}': {e}"
))),
}
}
}
@@ -8,7 +8,6 @@ use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::file_state;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
use async_trait::async_trait;
use futures::future::join_all;
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -51,7 +50,7 @@ struct ParallelAgentTask {
base_ref: Option<String>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ParallelAgentResult {
task_id: String,
@@ -460,25 +459,38 @@ impl Tool for SpawnParallelAgentsTool {
"[spawn_parallel_agents] prepared_tasks"
);
let futures =
prepared
.into_iter()
.map(|(definition, prompt, task, task_id, worktree_path)| {
let repo_root = action_root.clone();
async move {
run_one_parallel_task(
definition,
prompt,
task,
task_id,
worktree_path,
repo_root,
)
.await
}
});
// Fan the prepared workers out on the tinyagents graph (dispatch ->
// parallel worker nodes -> collect barrier) instead of a hand-rolled
// `join_all`. Results come back in prepared order, then we append them
// after the immediate (pre-flight failed) ones — the same ordering as
// before. `max_concurrency` = worker count preserves the prior
// all-at-once behaviour.
let max_concurrency = prepared.len().max(1);
let action_root_for_workers = action_root.clone();
let fanned = crate::openhuman::tinyagents::orchestration::run_parallel_fanout(
"spawn_parallel_agents",
prepared,
max_concurrency,
move |_i, (definition, prompt, task, task_id, worktree_path)| {
let repo_root = action_root_for_workers.clone();
async move {
run_one_parallel_task(
definition,
prompt,
task,
task_id,
worktree_path,
repo_root,
)
.await
}
},
)
.await
.map_err(|e| anyhow::anyhow!(e))?;
let mut results = immediate_results;
for result in join_all(futures).await {
for result in fanned {
match &result {
ParallelAgentResult {
success: true,
@@ -52,6 +52,19 @@ const PHASE_RUNNING: &str = "running";
const PHASE_COMPLETED: &str = "completed";
const PHASE_FAILED: &str = "failed";
/// One worker's outcome from the intra-phase graph fan-out (see
/// [`drive_phases`]). Rides in the fan-out graph's typed state, so it is `Clone`.
#[derive(Clone)]
struct PhaseWorkerOutcome {
/// The spawned child's orchestration id, recorded in `child_run_ids`.
/// `None` when the child was never spawned (cancelled / spawn error).
orchestration_id: Option<String>,
/// Completed child's output row appended to the phase outputs.
output: Option<Value>,
/// Failure reason when the worker did not complete successfully.
error: Option<String>,
}
// ───────────────────────────────────────────────────────────────────────────
// Cancellation registry
// ───────────────────────────────────────────────────────────────────────────
@@ -290,7 +303,7 @@ async fn run_engine_loop(config: &Config, run_id: &str, definition: WorkflowDefi
let outcome = match build_root_parent(config, "workflow_engine", "workflow", "workflow").await {
Ok(parent) => {
with_parent_context(parent, async {
drive_phases(config, run_id, &definition, &cancel).await
super::graph::drive_phases(config, run_id, &definition, &cancel).await
})
.await
}
@@ -327,53 +340,310 @@ async fn run_engine_loop(config: &Config, run_id: &str, definition: WorkflowDefi
clear_cancel_flag(run_id);
}
/// Topologically walk the phase DAG, executing each phase whose dependencies
/// are all `completed`. Persists after every phase. Returns `Ok(())` once every
/// phase is completed, the run is interrupted, or a phase fails (the terminal
/// status is written to the row before returning `Ok`). Returns `Err` only for
/// engine-internal failures (e.g. ledger write errors).
async fn drive_phases(
/// What the scheduler's `dispatch` step decided.
pub(super) enum PhaseSelection {
/// Execute this phase next.
Run(WorkflowPhase),
/// The run reached a terminal status (already persisted) — route to `done`.
Terminated,
}
/// Outcome of executing one phase in the `run_phase` step.
pub(super) enum PhaseExecOutcome {
/// The phase completed; `spawned` children were launched (added to the
/// run-wide `max_children` tally). Route back to `dispatch`.
Continue { spawned: u32 },
/// The run reached a terminal status (already persisted) — route to `done`.
Terminated,
}
/// `dispatch` step: reload the run, honour cancellation, and pick the next
/// runnable phase (pending, all deps `completed`). When none remains, persist the
/// terminal status (Completed / Failed) and return [`PhaseSelection::Terminated`].
pub(super) async fn select_next_phase(
config: &Config,
run_id: &str,
definition: &WorkflowDefinition,
cancel: &Arc<AtomicBool>,
) -> Result<()> {
use crate::openhuman::agent_orchestration::{
AgentOrchestrationSession, AgentStatus, SpawnAgentRequest, WaitAgentOptions,
};
session: &crate::openhuman::agent_orchestration::AgentOrchestrationSession,
) -> Result<PhaseSelection> {
// Reload so we read the latest phase_states (and a resume picks up persisted
// progress).
let run = get_workflow_run(config, run_id)?
.ok_or_else(|| anyhow!("workflow run {run_id} vanished mid-loop"))?;
let phase_states = run.phase_states.clone();
let child_run_ids = run.child_run_ids.clone();
let session = AgentOrchestrationSession::new(format!("workflow-engine-{run_id}"));
let mut total_spawned: u32 = 0;
// Cancellation check between phases.
if cancel.load(Ordering::SeqCst) {
log::debug!(
target: LOG_TARGET,
"[workflow_run_engine] loop.cancelled run={run_id}"
);
session.abort_all().await;
persist(
config,
&run,
phase_states,
child_run_ids,
WorkflowRunStatus::Interrupted,
None,
false,
)?;
return Ok(PhaseSelection::Terminated);
}
// Optional per-run model override. When present in the run input
// (`{"modelOverride": "..."}`), every child is forced onto the parent
// (root) provider with this model instead of its declarative workload
// provider. Production runs omit it (agents use their configured provider);
// deterministic mock-backend tests set it so children resolve to the
// injected mock provider.
let model_override = get_workflow_run(config, run_id)?
.and_then(|r| {
r.input
.get("modelOverride")
.and_then(Value::as_str)
.map(str::to_string)
})
.filter(|m| !m.trim().is_empty());
loop {
// Reload the run each iteration so we read the latest phase_states (and
// so a resume picks up persisted progress).
let run = get_workflow_run(config, run_id)?
.ok_or_else(|| anyhow!("workflow run {run_id} vanished mid-loop"))?;
let mut phase_states = run.phase_states.clone();
let mut child_run_ids = run.child_run_ids.clone();
// Cancellation check between phases.
if cancel.load(Ordering::SeqCst) {
// Find the next runnable phase: pending, with all deps completed.
let Some(phase) = next_runnable_phase(definition, &phase_states) else {
// No runnable phase left. Either everything is done, or we're blocked
// (which a validated DAG shouldn't be).
if all_phases_completed(definition, &phase_states) {
let summary = synthesize_summary(definition, &phase_states);
log::debug!(
target: LOG_TARGET,
"[workflow_run_engine] loop.cancelled run={run_id}"
"[workflow_run_engine] loop.completed run={run_id} summary_chars={}",
summary.as_deref().map(str::len).unwrap_or(0)
);
persist(
config,
&run,
phase_states,
child_run_ids,
WorkflowRunStatus::Completed,
summary,
true,
)?;
} else {
log::warn!(
target: LOG_TARGET,
"[workflow_run_engine] loop.stuck run={run_id} no_runnable_phase"
);
persist(
config,
&run,
phase_states,
child_run_ids,
WorkflowRunStatus::Failed,
Some("no runnable phase (dependency deadlock)".to_string()),
true,
)?;
}
return Ok(PhaseSelection::Terminated);
};
Ok(PhaseSelection::Run(phase.clone()))
}
/// `run_phase` step: mark the phase running, fan its agents out on the
/// intra-phase tinyagents graph (bounded by `default_concurrency`, capped by the
/// run-wide `max_children` budget), aggregate outcomes, and persist the new
/// phase state. Returns [`PhaseExecOutcome::Continue`] with the number of
/// children spawned, or [`PhaseExecOutcome::Terminated`] when the phase failed or
/// cancellation landed mid-phase (the terminal status is persisted first).
#[allow(clippy::too_many_arguments)]
pub(super) async fn execute_phase(
config: &Config,
run_id: &str,
definition: &WorkflowDefinition,
session: &crate::openhuman::agent_orchestration::AgentOrchestrationSession,
cancel: &Arc<AtomicBool>,
model_override: Option<String>,
phase: &WorkflowPhase,
total_spawned: u32,
) -> Result<PhaseExecOutcome> {
use crate::openhuman::agent_orchestration::{AgentStatus, SpawnAgentRequest, WaitAgentOptions};
// Reload so the phase state we mutate + persist is the latest projection.
let run = get_workflow_run(config, run_id)?
.ok_or_else(|| anyhow!("workflow run {run_id} vanished mid-phase"))?;
let mut phase_states = run.phase_states.clone();
let mut child_run_ids = run.child_run_ids.clone();
// Children launched *this* phase (the reducer delta added to `total_spawned`).
let mut spawned_this_phase: u32 = 0;
log::debug!(
target: LOG_TARGET,
"[workflow_run_engine] phase.start run={run_id} phase={} agents={} spawned_so_far={}",
phase.name,
phase.agent_ids.len(),
total_spawned
);
set_phase_status(&mut phase_states, &phase.name, PHASE_RUNNING, None);
persist(
config,
&run,
phase_states.clone(),
child_run_ids.clone(),
WorkflowRunStatus::Running,
None,
false,
)?;
// Thread prior phases' outputs into this phase's prompt context.
let upstream_context = upstream_outputs(definition, phase, &phase_states);
// Run the phase's agents on a tinyagents graph fan-out (dispatch ->
// parallel worker nodes bounded by `default_concurrency` -> collect
// barrier), never exceeding the run-wide `max_children` cap. Each worker
// spawns one child and waits for its terminal status; outcomes return in
// phase order.
let mut phase_outputs: Vec<Value> = Vec::new();
let mut phase_failed: Option<String> = None;
let concurrency = definition.default_concurrency.max(1) as usize;
let budget_left = definition.max_children.saturating_sub(total_spawned);
if budget_left == 0 {
phase_failed = Some(format!(
"max_children cap ({}) reached before phase '{}' completed",
definition.max_children, phase.name
));
} else {
// Cap this phase to the run-wide `max_children` budget; if the phase
// needs more workers than the budget allows we run as many as fit
// and then fail with the cap message (matching the legacy loop).
let capacity = budget_left as usize;
let phase_agents = phase.agent_ids.to_vec();
let capped = phase_agents.len() > capacity;
let to_run: Vec<(usize, String)> = phase_agents
.into_iter()
.take(capacity)
.enumerate()
.collect();
// Clones moved into the (`'static`) worker closure.
let session_for_workers = session.clone();
let cancel_for_workers = cancel.clone();
let run_input = run.input.clone();
let phase_owned = phase.clone();
let upstream_owned = upstream_context.clone();
let model_for_workers = model_override.clone();
let outcomes = crate::openhuman::tinyagents::orchestration::run_parallel_fanout(
&format!("workflow:{run_id}:{}", phase_owned.name),
to_run,
concurrency,
move |_node, (agent_index, agent_id)| {
let session = session_for_workers.clone();
let cancel = cancel_for_workers.clone();
let run_input = run_input.clone();
let phase = phase_owned.clone();
let upstream = upstream_owned.clone();
let model = model_for_workers.clone();
async move {
// Don't launch new children once cancellation has landed.
if cancel.load(Ordering::SeqCst) {
return PhaseWorkerOutcome {
orchestration_id: None,
output: None,
error: Some("cancelled before spawn".to_string()),
};
}
let prompt = phase_prompt(&run_input, &phase, agent_index, &upstream);
let resp = match session
.spawn_agent(SpawnAgentRequest {
agent_id: agent_id.clone(),
prompt,
model,
..Default::default()
})
.await
{
Ok(resp) => resp,
Err(err) => {
return PhaseWorkerOutcome {
orchestration_id: None,
output: None,
error: Some(format!("spawn failed for agent '{agent_id}': {err}")),
};
}
};
let oid = resp.orchestration_id.clone();
let wait = match session
.wait_agents(WaitAgentOptions {
orchestration_ids: vec![oid.clone()],
timeout_ms: None,
})
.await
{
Ok(w) => w,
Err(err) => {
return PhaseWorkerOutcome {
orchestration_id: Some(oid),
output: None,
error: Some(format!("wait_agents failed: {err}")),
};
}
};
match wait.agents.into_iter().next() {
Some(s) => match s.status {
AgentStatus::Completed => PhaseWorkerOutcome {
orchestration_id: Some(oid),
output: Some(json!({
"orchestrationId": s.orchestration_id,
"agentId": s.agent_id,
"output": s.result_summary.clone().unwrap_or_default(),
})),
error: None,
},
AgentStatus::Failed | AgentStatus::Cancelled | AgentStatus::Closed => {
PhaseWorkerOutcome {
orchestration_id: Some(oid),
output: None,
error: Some(format!(
"child '{}' (agent '{}') ended {}: {}",
s.orchestration_id,
s.agent_id,
serde_json::to_value(s.status)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| "non-completed".to_string()),
s.error.clone().unwrap_or_default()
)),
}
}
AgentStatus::Pending | AgentStatus::Running | AgentStatus::Waiting => {
PhaseWorkerOutcome {
orchestration_id: Some(oid),
output: None,
error: Some(format!(
"child '{}' returned non-terminal status",
s.orchestration_id
)),
}
}
},
None => PhaseWorkerOutcome {
orchestration_id: Some(oid),
output: None,
error: Some("child returned no snapshot".to_string()),
},
}
}
},
)
.await
.map_err(|e| anyhow!(e))?;
// Aggregate worker outcomes in phase order: record every spawned
// child id, collect completed outputs, and surface the first failure.
for outcome in outcomes {
if let Some(oid) = outcome.orchestration_id {
spawned_this_phase += 1;
child_run_ids.push(oid);
}
match outcome.output {
Some(out) => phase_outputs.push(out),
None => {
if phase_failed.is_none() {
phase_failed = outcome.error;
}
}
}
}
// Cancellation landed mid-phase: abort stragglers and interrupt.
if cancel.load(Ordering::SeqCst) {
session.abort_all().await;
persist(
config,
@@ -384,223 +654,67 @@ async fn drive_phases(
None,
false,
)?;
return Ok(());
return Ok(PhaseExecOutcome::Terminated);
}
// Find the next runnable phase: pending, with all deps completed.
let Some(phase) = next_runnable_phase(definition, &phase_states) else {
// No runnable phase left. Either everything is done, or we're
// blocked (which a validated DAG shouldn't be).
if all_phases_completed(definition, &phase_states) {
let summary = synthesize_summary(definition, &phase_states);
log::debug!(
target: LOG_TARGET,
"[workflow_run_engine] loop.completed run={run_id} summary_chars={}",
summary.as_deref().map(str::len).unwrap_or(0)
);
persist(
config,
&run,
phase_states,
child_run_ids,
WorkflowRunStatus::Completed,
summary,
true,
)?;
} else {
log::warn!(
target: LOG_TARGET,
"[workflow_run_engine] loop.stuck run={run_id} no_runnable_phase"
);
persist(
config,
&run,
phase_states,
child_run_ids,
WorkflowRunStatus::Failed,
Some("no runnable phase (dependency deadlock)".to_string()),
true,
)?;
}
return Ok(());
};
if capped && phase_failed.is_none() {
phase_failed = Some(format!(
"max_children cap ({}) reached before phase '{}' completed",
definition.max_children, phase.name
));
}
}
log::debug!(
if let Some(reason) = phase_failed {
log::warn!(
target: LOG_TARGET,
"[workflow_run_engine] phase.start run={run_id} phase={} agents={} spawned_so_far={}",
phase.name,
phase.agent_ids.len(),
total_spawned
);
set_phase_status(&mut phase_states, &phase.name, PHASE_RUNNING, None);
persist(
config,
&run,
phase_states.clone(),
child_run_ids.clone(),
WorkflowRunStatus::Running,
None,
false,
)?;
// Thread prior phases' outputs into this phase's prompt context.
let upstream_context = upstream_outputs(definition, phase, &phase_states);
// Spawn the phase's agents in concurrency-bounded batches, never
// exceeding the run-wide `max_children` cap.
let mut phase_outputs: Vec<Value> = Vec::new();
let mut phase_failed: Option<String> = None;
let mut index_in_phase = 0usize;
let concurrency = definition.default_concurrency.max(1);
let mut pending = phase.agent_ids.to_vec();
'phase: while !pending.is_empty() {
// Cancellation can also land mid-phase between batches.
if cancel.load(Ordering::SeqCst) {
session.abort_all().await;
persist(
config,
&run,
phase_states,
child_run_ids,
WorkflowRunStatus::Interrupted,
None,
false,
)?;
return Ok(());
}
let budget_left = definition.max_children.saturating_sub(total_spawned);
if budget_left == 0 {
phase_failed = Some(format!(
"max_children cap ({}) reached before phase '{}' completed",
definition.max_children, phase.name
));
break 'phase;
}
let batch_size = (concurrency as usize)
.min(budget_left as usize)
.min(pending.len())
.max(1);
let batch: Vec<String> = pending.drain(..batch_size).collect();
let mut batch_ids: Vec<(String, String)> = Vec::new(); // (orchestration_id, agent_id)
for agent_id in &batch {
let prompt = phase_prompt(&run.input, phase, index_in_phase, &upstream_context);
index_in_phase += 1;
match session
.spawn_agent(SpawnAgentRequest {
agent_id: agent_id.clone(),
prompt,
model: model_override.clone(),
..Default::default()
})
.await
{
Ok(resp) => {
total_spawned += 1;
child_run_ids.push(resp.orchestration_id.clone());
batch_ids.push((resp.orchestration_id, agent_id.clone()));
}
Err(err) => {
phase_failed = Some(format!("spawn failed for agent '{agent_id}': {err}"));
break 'phase;
}
}
}
// Wait for this batch to reach terminal status.
let wait = session
.wait_agents(WaitAgentOptions {
orchestration_ids: batch_ids.iter().map(|(id, _)| id.clone()).collect(),
timeout_ms: None,
})
.await
.map_err(|e| anyhow!("wait_agents failed: {e}"))?;
for snapshot in &wait.agents {
match snapshot.status {
AgentStatus::Completed => {
phase_outputs.push(json!({
"orchestrationId": snapshot.orchestration_id,
"agentId": snapshot.agent_id,
"output": snapshot.result_summary.clone().unwrap_or_default(),
}));
}
AgentStatus::Failed | AgentStatus::Cancelled | AgentStatus::Closed => {
phase_failed = Some(format!(
"child '{}' (agent '{}') ended {}: {}",
snapshot.orchestration_id,
snapshot.agent_id,
serde_json::to_value(snapshot.status)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| "non-completed".to_string()),
snapshot.error.clone().unwrap_or_default()
));
break 'phase;
}
AgentStatus::Pending | AgentStatus::Running | AgentStatus::Waiting => {
// wait_agents with no timeout only returns on terminal,
// so this is defensive.
phase_failed = Some(format!(
"child '{}' returned non-terminal status",
snapshot.orchestration_id
));
break 'phase;
}
}
}
}
if let Some(reason) = phase_failed {
log::warn!(
target: LOG_TARGET,
"[workflow_run_engine] phase.failed run={run_id} phase={} reason={reason}",
phase.name
);
set_phase_status(
&mut phase_states,
&phase.name,
PHASE_FAILED,
Some(json!([])),
);
set_phase_reason(&mut phase_states, &phase.name, &reason);
persist(
config,
&run,
phase_states,
child_run_ids,
WorkflowRunStatus::Failed,
Some(reason),
true,
)?;
return Ok(());
}
log::debug!(
target: LOG_TARGET,
"[workflow_run_engine] phase.done run={run_id} phase={} outputs={}",
phase.name,
phase_outputs.len()
"[workflow_run_engine] phase.failed run={run_id} phase={} reason={reason}",
phase.name
);
set_phase_status(
&mut phase_states,
&phase.name,
PHASE_COMPLETED,
Some(Value::Array(phase_outputs)),
PHASE_FAILED,
Some(json!([])),
);
set_phase_reason(&mut phase_states, &phase.name, &reason);
persist(
config,
&run,
phase_states,
child_run_ids,
WorkflowRunStatus::Running,
None,
false,
WorkflowRunStatus::Failed,
Some(reason),
true,
)?;
return Ok(PhaseExecOutcome::Terminated);
}
log::debug!(
target: LOG_TARGET,
"[workflow_run_engine] phase.done run={run_id} phase={} outputs={}",
phase.name,
phase_outputs.len()
);
set_phase_status(
&mut phase_states,
&phase.name,
PHASE_COMPLETED,
Some(Value::Array(phase_outputs)),
);
persist(
config,
&run,
phase_states,
child_run_ids,
WorkflowRunStatus::Running,
None,
false,
)?;
Ok(PhaseExecOutcome::Continue {
spawned: spawned_this_phase,
})
}
// ───────────────────────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
//! Engine unit tests (#3375 PR2).
//!
//! These exercise the phase scheduler ([`super::drive_phases`]) directly with a
//! These exercise the phase scheduler ([`super::super::graph::drive_phases`]) directly with a
//! mock `Provider` so child agents resolve deterministically and never touch the
//! network. The full [`super::start_workflow_run`] entry point (which builds a
//! real `Agent` from config) is covered by the JSON-RPC e2e test over the live
@@ -21,6 +21,9 @@ use serde_json::{json, Value};
use tokio::time::{sleep, Duration};
use super::*;
// The phase scheduler moved to the tinyagents-backed `graph` submodule in #4249;
// its signature is unchanged (`drive_phases(config, run_id, definition, cancel)`).
use super::super::graph::drive_phases;
use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
use crate::openhuman::agent::harness::fork_context::{with_parent_context, ParentExecutionContext};
use crate::openhuman::config::{AgentConfig, Config};
@@ -0,0 +1,222 @@
//! The **workflow scheduler graph** (issue #4249, Phase 4).
//!
//! This is the `workflow_runs` folder's `graph.rs` per the per-folder graph
//! convention: the durable workflow run's phase-DAG scheduler expressed as a
//! `tinyagents` conditional-routing graph. A `dispatch` node selects the next
//! runnable phase and a `run_phase` node executes it, looping
//! `dispatch ⇄ run_phase` until no phase remains, then routing to `done`.
//!
//! The phase-execution logic (`select_next_phase`, `execute_phase`) and the run
//! lifecycle (`start_workflow_run` / `stop_workflow_run` / `resume_workflow_run`)
//! stay in [`super::engine`]; this module owns only the graph state machine that
//! drives them.
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use serde_json::Value;
use crate::openhuman::config::Config;
use crate::openhuman::session_db::run_ledger::get_workflow_run;
use super::engine::{execute_phase, select_next_phase, PhaseExecOutcome, PhaseSelection};
use super::types::{WorkflowDefinition, WorkflowPhase};
/// Lift an engine-internal `anyhow` error into the scheduler graph's error type
/// so a ledger-write/spawn failure fails the run (and propagates back out via
/// [`drive_phases`]). A *phase* that merely failed is not an error here — it is a
/// normal `Terminated` outcome that persists `Failed` and routes to `done`.
fn graph_err(e: anyhow::Error) -> tinyagents::TinyAgentsError {
tinyagents::TinyAgentsError::Graph(e.to_string())
}
/// Typed state threaded through the scheduler graph: the phase `dispatch`
/// selected for `run_phase`, and the running tally of spawned children (the
/// `max_children` budget counter, carried across the dispatch⇄run_phase cycle).
#[derive(Clone, Default)]
struct SchedulerState {
phase: Option<WorkflowPhase>,
total_spawned: u32,
}
/// Reducer updates emitted by the scheduler graph nodes.
enum SchedulerUpdate {
/// `dispatch` chose the next phase to run.
SelectPhase(WorkflowPhase),
/// `run_phase` spawned this many children; fold into `total_spawned`.
AddSpawned(u32),
/// Terminal node fired; no state change.
Noop,
}
/// Topologically walk the phase DAG on a `tinyagents` conditional-routing graph
/// (issue #4249, Phase 4): a `dispatch` node selects the next runnable phase and
/// a `run_phase` node executes it, looping `dispatch ⇄ run_phase` until no phase
/// remains, then routing to `done`:
///
/// ```text
/// dispatch ──phase──► run_phase ──► dispatch ──none/terminal──► done
/// ```
///
/// This replaces the hand-rolled `loop {}` scheduler with a graph state machine
/// while keeping the durable `workflow_runs` row as the source of truth (every
/// node reloads + persists it, so resume picks up persisted phase progress and
/// the read controllers see the same projection). Returns `Ok(())` once a
/// terminal status (Completed / Failed / Interrupted) is written; returns `Err`
/// only for engine-internal failures (ledger writes, graph mechanics).
pub(super) async fn drive_phases(
config: &Config,
run_id: &str,
definition: &WorkflowDefinition,
cancel: &Arc<AtomicBool>,
) -> Result<()> {
use crate::openhuman::agent_orchestration::AgentOrchestrationSession;
use crate::openhuman::tinyagents::observability::GraphTracingSink;
use tinyagents::graph::recursion::RecursionPolicy;
use tinyagents::graph::{ClosureStateReducer, Command, GraphBuilder, NodeContext, NodeResult};
let session = AgentOrchestrationSession::new(format!("workflow-engine-{run_id}"));
// Optional per-run model override. When present in the run input
// (`{"modelOverride": "..."}`), every child is forced onto the parent
// (root) provider with this model instead of its declarative workload
// provider. Production runs omit it (agents use their configured provider);
// deterministic mock-backend tests set it so children resolve to the
// injected mock provider.
let model_override = get_workflow_run(config, run_id)?
.and_then(|r| {
r.input
.get("modelOverride")
.and_then(Value::as_str)
.map(str::to_string)
})
.filter(|m| !m.trim().is_empty());
// `'static` captures for the graph node handlers (they are `Fn`, re-entered
// once per phase, so each invocation re-clones from these).
let config = Arc::new(config.clone());
let definition = Arc::new(definition.clone());
let run_id_owned = run_id.to_string();
let cancel = cancel.clone();
let mut builder = GraphBuilder::<SchedulerState, SchedulerUpdate>::new().set_reducer(
ClosureStateReducer::new(|mut s: SchedulerState, u: SchedulerUpdate| {
match u {
SchedulerUpdate::SelectPhase(p) => s.phase = Some(p),
SchedulerUpdate::AddSpawned(n) => s.total_spawned += n,
SchedulerUpdate::Noop => {}
}
Ok(s)
}),
);
// `dispatch`: pick the next runnable phase, or terminate (already persisted).
{
let config = config.clone();
let definition = definition.clone();
let run_id = run_id_owned.clone();
let cancel = cancel.clone();
let session = session.clone();
builder = builder.add_node("dispatch", move |_s: SchedulerState, _c: NodeContext| {
let config = config.clone();
let definition = definition.clone();
let run_id = run_id.clone();
let cancel = cancel.clone();
let session = session.clone();
async move {
match select_next_phase(&config, &run_id, &definition, &cancel, &session)
.await
.map_err(graph_err)?
{
PhaseSelection::Run(phase) => Ok(NodeResult::Command(
Command::default()
.with_update(SchedulerUpdate::SelectPhase(phase))
.with_goto(["run_phase"]),
)),
PhaseSelection::Terminated => {
Ok(NodeResult::Command(Command::default().with_goto(["done"])))
}
}
}
});
}
// `run_phase`: execute the selected phase, then loop back to `dispatch` or
// terminate (on phase failure / mid-phase cancellation).
{
let config = config.clone();
let definition = definition.clone();
let run_id = run_id_owned.clone();
let cancel = cancel.clone();
let session = session.clone();
let model_override = model_override.clone();
builder = builder.add_node("run_phase", move |s: SchedulerState, _c: NodeContext| {
let config = config.clone();
let definition = definition.clone();
let run_id = run_id.clone();
let cancel = cancel.clone();
let session = session.clone();
let model_override = model_override.clone();
async move {
let phase = s.phase.clone().ok_or_else(|| {
tinyagents::TinyAgentsError::Graph(
"workflow run_phase reached with no selected phase".to_string(),
)
})?;
match execute_phase(
&config,
&run_id,
&definition,
&session,
&cancel,
model_override,
&phase,
s.total_spawned,
)
.await
.map_err(graph_err)?
{
PhaseExecOutcome::Continue { spawned } => Ok(NodeResult::Command(
Command::default()
.with_update(SchedulerUpdate::AddSpawned(spawned))
.with_goto(["dispatch"]),
)),
PhaseExecOutcome::Terminated => {
Ok(NodeResult::Command(Command::default().with_goto(["done"])))
}
}
}
});
}
let phase_count = definition.phases.len();
let graph = builder
.add_node("done", |_s: SchedulerState, _c: NodeContext| async move {
Ok(NodeResult::Update(SchedulerUpdate::Noop))
})
.set_entry("dispatch")
.mark_command_routing("dispatch")
.mark_command_routing("run_phase")
.set_finish("done")
.compile()
.map_err(|e| anyhow!("workflow scheduler graph compile failed: {e}"))?
.with_event_sink(Arc::new(GraphTracingSink::new(format!(
"workflow:{run_id_owned}"
))))
// Bound the dispatch⇄run_phase cycle as a backstop to the DAG's own
// termination: `dispatch` is visited once per phase plus a final
// no-phase visit, `run_phase` once per phase. A validated DAG always
// drains, so this only guards a malformed definition.
.with_recursion_policy(RecursionPolicy {
max_visits_per_node: Some(phase_count + 2),
max_total_steps: (phase_count + 1) * 3 + 16,
..RecursionPolicy::default()
});
graph
.run(SchedulerState::default())
.await
.map_err(|e| anyhow!("workflow scheduler graph run failed: {e}"))?;
Ok(())
}
@@ -21,6 +21,7 @@
//! handles SKILL.md / WORKFLOW.md bundle discovery.
mod engine;
mod graph;
mod ops;
mod schemas;
pub mod types;
@@ -0,0 +1,13 @@
//! Turn graph for the `account_admin_agent` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `archivist` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `code_executor` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `context_scout` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `critic` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `crypto_agent` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `desktop_control_agent` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `goals_agent` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `help` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `image_agent` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `integrations_agent` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -34,6 +34,7 @@
//! into the global registry, where they replace built-ins on `id`
//! collision.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
use crate::openhuman::agent::harness::definition::{
validate_tier_transition, AgentDefinition, AgentTier, DefinitionSource, PromptBuilder,
PromptSource, SubagentEntry,
@@ -53,6 +54,11 @@ pub struct BuiltinAgent {
/// with a populated [`crate::openhuman::agent::harness::definition::PromptContext`]
/// so the returned body can branch on runtime state.
pub prompt_fn: PromptBuilder,
/// Turn-graph selector. Invoked at load time to stamp the agent's
/// [`AgentGraph`] onto its [`AgentDefinition`] (mirrors `prompt_fn`). The
/// agent's `graph.rs::graph()` returns [`AgentGraph::Default`] (shared graph)
/// or [`AgentGraph::Custom`] (bespoke graph).
pub graph_fn: fn() -> AgentGraph,
}
/// Every built-in agent, in stable display order.
@@ -63,186 +69,223 @@ pub const BUILTINS: &[BuiltinAgent] = &[
id: "orchestrator",
toml: include_str!("orchestrator/agent.toml"),
prompt_fn: super::orchestrator::prompt::build,
graph_fn: super::orchestrator::graph::graph,
},
BuiltinAgent {
id: "planner",
toml: include_str!("planner/agent.toml"),
prompt_fn: super::planner::prompt::build,
graph_fn: super::planner::graph::graph,
},
BuiltinAgent {
id: "code_executor",
toml: include_str!("code_executor/agent.toml"),
prompt_fn: super::code_executor::prompt::build,
graph_fn: super::code_executor::graph::graph,
},
BuiltinAgent {
id: "integrations_agent",
toml: include_str!("integrations_agent/agent.toml"),
prompt_fn: super::integrations_agent::prompt::build,
graph_fn: super::integrations_agent::graph::graph,
},
BuiltinAgent {
id: "crypto_agent",
toml: include_str!("crypto_agent/agent.toml"),
prompt_fn: super::crypto_agent::prompt::build,
graph_fn: super::crypto_agent::graph::graph,
},
BuiltinAgent {
id: "markets_agent",
toml: include_str!("markets_agent/agent.toml"),
prompt_fn: super::markets_agent::prompt::build,
graph_fn: super::markets_agent::graph::graph,
},
BuiltinAgent {
id: "tinyplace_agent",
toml: include_str!("../../tinyplace/agent/agent.toml"),
prompt_fn: crate::openhuman::tinyplace::agent::prompt::build,
graph_fn: crate::openhuman::tinyplace::agent::graph::graph,
},
BuiltinAgent {
id: "tools_agent",
toml: include_str!("tools_agent/agent.toml"),
prompt_fn: super::tools_agent::prompt::build,
graph_fn: super::tools_agent::graph::graph,
},
BuiltinAgent {
id: "task_manager_agent",
toml: include_str!("task_manager_agent/agent.toml"),
prompt_fn: super::task_manager_agent::prompt::build,
graph_fn: super::task_manager_agent::graph::graph,
},
BuiltinAgent {
id: "settings_agent",
toml: include_str!("settings_agent/agent.toml"),
prompt_fn: super::settings_agent::prompt::build,
graph_fn: super::settings_agent::graph::graph,
},
BuiltinAgent {
id: "profile_memory_agent",
toml: include_str!("profile_memory_agent/agent.toml"),
prompt_fn: super::profile_memory_agent::prompt::build,
graph_fn: super::profile_memory_agent::graph::graph,
},
BuiltinAgent {
id: "account_admin_agent",
toml: include_str!("account_admin_agent/agent.toml"),
prompt_fn: super::account_admin_agent::prompt::build,
graph_fn: super::account_admin_agent::graph::graph,
},
BuiltinAgent {
id: "screen_awareness_agent",
toml: include_str!("screen_awareness_agent/agent.toml"),
prompt_fn: super::screen_awareness_agent::prompt::build,
graph_fn: super::screen_awareness_agent::graph::graph,
},
BuiltinAgent {
id: "scheduler_agent",
toml: include_str!("scheduler_agent/agent.toml"),
prompt_fn: super::scheduler_agent::prompt::build,
graph_fn: super::scheduler_agent::graph::graph,
},
BuiltinAgent {
id: "presentation_agent",
toml: include_str!("presentation_agent/agent.toml"),
prompt_fn: super::presentation_agent::prompt::build,
graph_fn: super::presentation_agent::graph::graph,
},
BuiltinAgent {
id: "desktop_control_agent",
toml: include_str!("desktop_control_agent/agent.toml"),
prompt_fn: super::desktop_control_agent::prompt::build,
graph_fn: super::desktop_control_agent::graph::graph,
},
BuiltinAgent {
id: "tool_maker",
toml: include_str!("tool_maker/agent.toml"),
prompt_fn: super::tool_maker::prompt::build,
graph_fn: super::tool_maker::graph::graph,
},
BuiltinAgent {
id: "skill_creator",
toml: include_str!("skill_creator/agent.toml"),
prompt_fn: super::skill_creator::prompt::build,
graph_fn: super::skill_creator::graph::graph,
},
BuiltinAgent {
id: "researcher",
toml: include_str!("researcher/agent.toml"),
prompt_fn: super::researcher::prompt::build,
graph_fn: super::researcher::graph::graph,
},
BuiltinAgent {
id: "context_scout",
toml: include_str!("context_scout/agent.toml"),
prompt_fn: super::context_scout::prompt::build,
graph_fn: super::context_scout::graph::graph,
},
BuiltinAgent {
id: "critic",
toml: include_str!("critic/agent.toml"),
prompt_fn: super::critic::prompt::build,
graph_fn: super::critic::graph::graph,
},
BuiltinAgent {
id: "vision_agent",
toml: include_str!("vision_agent/agent.toml"),
prompt_fn: super::vision_agent::prompt::build,
graph_fn: super::vision_agent::graph::graph,
},
BuiltinAgent {
id: "image_agent",
toml: include_str!("image_agent/agent.toml"),
prompt_fn: super::image_agent::prompt::build,
graph_fn: super::image_agent::graph::graph,
},
BuiltinAgent {
id: "video_agent",
toml: include_str!("video_agent/agent.toml"),
prompt_fn: super::video_agent::prompt::build,
graph_fn: super::video_agent::graph::graph,
},
BuiltinAgent {
id: "archivist",
toml: include_str!("archivist/agent.toml"),
prompt_fn: super::archivist::prompt::build,
graph_fn: super::archivist::graph::graph,
},
BuiltinAgent {
id: "goals_agent",
toml: include_str!("goals_agent/agent.toml"),
prompt_fn: super::goals_agent::prompt::build,
graph_fn: super::goals_agent::graph::graph,
},
BuiltinAgent {
id: "trigger_triage",
toml: include_str!("trigger_triage/agent.toml"),
prompt_fn: super::trigger_triage::prompt::build,
graph_fn: super::trigger_triage::graph::graph,
},
BuiltinAgent {
id: "trigger_reactor",
toml: include_str!("trigger_reactor/agent.toml"),
prompt_fn: super::trigger_reactor::prompt::build,
graph_fn: super::trigger_reactor::graph::graph,
},
BuiltinAgent {
id: "morning_briefing",
toml: include_str!("morning_briefing/agent.toml"),
prompt_fn: super::morning_briefing::prompt::build,
graph_fn: super::morning_briefing::graph::graph,
},
BuiltinAgent {
id: "summarizer",
toml: include_str!("summarizer/agent.toml"),
prompt_fn: super::summarizer::prompt::build,
graph_fn: super::summarizer::graph::graph,
},
BuiltinAgent {
id: "help",
toml: include_str!("help/agent.toml"),
prompt_fn: super::help::prompt::build,
graph_fn: super::help::graph::graph,
},
BuiltinAgent {
id: "mcp_setup",
toml: include_str!("mcp_setup/agent.toml"),
prompt_fn: super::mcp_setup::prompt::build,
graph_fn: super::mcp_setup::graph::graph,
},
BuiltinAgent {
id: "mcp_agent",
toml: include_str!("mcp_agent/agent.toml"),
prompt_fn: super::mcp_agent::prompt::build,
graph_fn: super::mcp_agent::graph::graph,
},
BuiltinAgent {
id: "skill_setup",
toml: include_str!("../../skill_registry/agent/skill_setup/agent.toml"),
prompt_fn: crate::openhuman::skill_registry::agent::skill_setup::prompt::build,
graph_fn: crate::openhuman::skill_registry::agent::skill_setup::graph::graph,
},
BuiltinAgent {
id: "skill_executor",
toml: include_str!("../../skill_runtime/agent/skill_executor/agent.toml"),
prompt_fn: crate::openhuman::skill_runtime::agent::skill_executor::prompt::build,
graph_fn: crate::openhuman::skill_runtime::agent::skill_executor::graph::graph,
},
BuiltinAgent {
id: "agent_memory",
toml: include_str!("../../agent_memory/agent/agent.toml"),
prompt_fn: crate::openhuman::agent_memory::agent::prompt::build,
graph_fn: crate::openhuman::agent_memory::agent::graph::graph,
},
BuiltinAgent {
id: "subconscious",
toml: include_str!("../../subconscious/agent/agent.toml"),
prompt_fn: crate::openhuman::subconscious::agent::prompt::build,
graph_fn: crate::openhuman::subconscious::agent::graph::graph,
},
];
@@ -347,6 +390,11 @@ fn parse_builtin(b: &BuiltinAgent) -> Result<AgentDefinition> {
def.system_prompt = PromptSource::Dynamic(b.prompt_fn);
def.source = DefinitionSource::Builtin;
// Install the agent's turn-graph selection (issue #4249) — the runtime
// analogue of the prompt builder above. Default agents resolve to
// `AgentGraph::Default` (the shared sub-agent turn graph).
def.graph = (b.graph_fn)();
// Sanity check: file layout id must match declared TOML id. This
// catches copy-paste mistakes where someone forgets to update the
// `id` field after duplicating a folder.
@@ -0,0 +1,13 @@
//! Turn graph for the `markets_agent` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `mcp_agent` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `mcp_setup` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;
@@ -0,0 +1,13 @@
//! Turn graph for the `morning_briefing` built-in agent.
//!
//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see
//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with
//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph.
use crate::openhuman::agent::harness::agent_graph::AgentGraph;
/// Select this agent's turn graph. This is a default agent — it uses the shared
/// default graph rather than defining its own.
pub fn graph() -> AgentGraph {
AgentGraph::Default
}
@@ -1 +1,2 @@
pub mod graph;
pub mod prompt;

Some files were not shown because too many files have changed in this diff Show More