mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
agent_graph: LangGraph-style state-machine harness — checkpointing, HITL, observability (#4249) (#4261)
This commit is contained in:
@@ -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
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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 ≈ 85–1100 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -158,6 +158,7 @@ mod tests {
|
||||
delegate_name: None,
|
||||
agent_tier: AgentTier::Worker,
|
||||
source: DefinitionSource::Builtin,
|
||||
graph: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
//!
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 +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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 plan→execute→review→finalize 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
Reference in New Issue
Block a user