mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Agent harness: USD cost tracking, stop hooks, per-tool result caps (#1268)
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
//! Per-turn cost accounting for an agent's tool-call loop.
|
||||
//!
|
||||
//! Each provider response carries an optional [`UsageInfo`] block with
|
||||
//! `input_tokens`, `output_tokens`, `cached_input_tokens`, and an
|
||||
//! authoritative `charged_amount_usd` populated by the OpenHuman
|
||||
//! backend. [`TurnCost`] sums those across every provider call inside a
|
||||
//! single turn so the harness can:
|
||||
//!
|
||||
//! - emit per-iteration cost telemetry via
|
||||
//! [`crate::openhuman::agent::progress::AgentProgress::TurnCostUpdated`];
|
||||
//! - feed an upcoming budget stop-hook (mid-turn USD cap);
|
||||
//! - log accurate end-of-turn cost lines.
|
||||
//!
|
||||
//! When `charged_amount_usd` is zero (older backend builds, providers
|
||||
//! that don't surface billing), we fall back to a simple token-rate
|
||||
//! estimate via [`estimate_call_cost_usd`] keyed on the model tier
|
||||
//! name. The estimate is a floor — directly-billed cost from the
|
||||
//! backend always wins when available.
|
||||
//!
|
||||
//! The pricing table is intentionally tiny and only keyed on the
|
||||
//! abstract tier names the core uses (`agentic-v1`, `reasoning-v1`,
|
||||
//! `coding-v1`). The backend resolves them to concrete vendor models;
|
||||
//! cents-per-Mtok at the tier level is good enough for client-side
|
||||
//! telemetry and budget gating. PRs adding new tiers should add a row.
|
||||
|
||||
use crate::openhuman::providers::UsageInfo;
|
||||
|
||||
/// Per-million-token rates for a single model tier.
|
||||
///
|
||||
/// All prices are USD per million tokens. `cached_input_per_mtok_usd`
|
||||
/// applies to the `cached_input_tokens` portion of the usage block (KV
|
||||
/// prefix cache hits on supporting backends); the remaining
|
||||
/// `input_tokens - cached_input_tokens` are charged at
|
||||
/// `input_per_mtok_usd`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ModelPricing {
|
||||
/// Tier identifier, e.g. `"agentic-v1"`.
|
||||
pub model: &'static str,
|
||||
/// Standard prompt rate, USD per million input tokens.
|
||||
pub input_per_mtok_usd: f64,
|
||||
/// Cached-prefix prompt rate, USD per million cached input tokens.
|
||||
pub cached_input_per_mtok_usd: f64,
|
||||
/// Completion rate, USD per million output tokens.
|
||||
pub output_per_mtok_usd: f64,
|
||||
}
|
||||
|
||||
/// Conservative fallback when nothing in the table matches. Picked so
|
||||
/// budget caps still bite on unknown models rather than reading as $0.
|
||||
const FALLBACK_PRICING: ModelPricing = ModelPricing {
|
||||
model: "<fallback>",
|
||||
input_per_mtok_usd: 3.00,
|
||||
cached_input_per_mtok_usd: 0.30,
|
||||
output_per_mtok_usd: 15.00,
|
||||
};
|
||||
|
||||
/// Static price table keyed by tier name.
|
||||
///
|
||||
/// These are the OpenHuman tier handles, not concrete vendor model
|
||||
/// strings — the backend chooses which underlying Claude / GPT / etc.
|
||||
/// model serves each tier. Numbers track the public Anthropic price
|
||||
/// list at the time of writing for the tiers' default mappings; treat
|
||||
/// them as best-effort estimates for cases where the backend doesn't
|
||||
/// echo `charged_amount_usd`.
|
||||
pub const PRICING_TABLE: &[ModelPricing] = &[
|
||||
// Reasoning tier — currently maps to Claude Opus 4.x family.
|
||||
ModelPricing {
|
||||
model: "reasoning-v1",
|
||||
input_per_mtok_usd: 15.00,
|
||||
cached_input_per_mtok_usd: 1.50,
|
||||
output_per_mtok_usd: 75.00,
|
||||
},
|
||||
// Agentic tier — maps to Sonnet-class models.
|
||||
ModelPricing {
|
||||
model: "agentic-v1",
|
||||
input_per_mtok_usd: 3.00,
|
||||
cached_input_per_mtok_usd: 0.30,
|
||||
output_per_mtok_usd: 15.00,
|
||||
},
|
||||
// Coding tier — Sonnet-class.
|
||||
ModelPricing {
|
||||
model: "coding-v1",
|
||||
input_per_mtok_usd: 3.00,
|
||||
cached_input_per_mtok_usd: 0.30,
|
||||
output_per_mtok_usd: 15.00,
|
||||
},
|
||||
];
|
||||
|
||||
/// Look up pricing for a model name, falling back to [`FALLBACK_PRICING`].
|
||||
///
|
||||
/// Matching is exact on the canonical tier name and case-insensitive on
|
||||
/// concrete vendor names (so `"claude-opus"` still hits the
|
||||
/// reasoning-tier row when callers pass an underlying model string).
|
||||
pub fn lookup_pricing(model: &str) -> ModelPricing {
|
||||
if let Some(row) = PRICING_TABLE.iter().find(|row| row.model == model) {
|
||||
return *row;
|
||||
}
|
||||
let lower = model.to_ascii_lowercase();
|
||||
if lower.contains("opus") {
|
||||
return PRICING_TABLE[0];
|
||||
}
|
||||
if lower.contains("coding") {
|
||||
return PRICING_TABLE[2];
|
||||
}
|
||||
if lower.contains("sonnet") || lower.contains("agentic") {
|
||||
return PRICING_TABLE[1];
|
||||
}
|
||||
FALLBACK_PRICING
|
||||
}
|
||||
|
||||
/// Estimate the USD cost of a single provider call from its token
|
||||
/// usage. Used as a fallback when `charged_amount_usd` is missing.
|
||||
pub fn estimate_call_cost_usd(model: &str, usage: &UsageInfo) -> f64 {
|
||||
let pricing = lookup_pricing(model);
|
||||
let cached = usage.cached_input_tokens;
|
||||
let standard_input = usage.input_tokens.saturating_sub(cached);
|
||||
let m = 1_000_000.0_f64;
|
||||
(standard_input as f64) / m * pricing.input_per_mtok_usd
|
||||
+ (cached as f64) / m * pricing.cached_input_per_mtok_usd
|
||||
+ (usage.output_tokens as f64) / m * pricing.output_per_mtok_usd
|
||||
}
|
||||
|
||||
/// Pick the most authoritative USD figure for a single provider call.
|
||||
///
|
||||
/// Backend-reported `charged_amount_usd` wins whenever it's > 0;
|
||||
/// otherwise we fall back to [`estimate_call_cost_usd`].
|
||||
pub fn call_cost_usd(model: &str, usage: &UsageInfo) -> f64 {
|
||||
if usage.charged_amount_usd > 0.0 {
|
||||
usage.charged_amount_usd
|
||||
} else {
|
||||
estimate_call_cost_usd(model, usage)
|
||||
}
|
||||
}
|
||||
|
||||
/// Running cost / token tally across every provider call inside a
|
||||
/// single turn of the tool-call loop.
|
||||
///
|
||||
/// `charged_usd` is the sum of authoritative `charged_amount_usd`
|
||||
/// values; `estimated_usd` adds the fallback estimate for any call that
|
||||
/// lacked one. `total_usd()` returns whichever has more signal.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TurnCost {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub charged_usd: f64,
|
||||
pub estimated_usd: f64,
|
||||
pub call_count: u32,
|
||||
}
|
||||
|
||||
impl TurnCost {
|
||||
/// New empty accumulator.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Fold a single provider call's usage into the running totals.
|
||||
pub fn add_call(&mut self, model: &str, usage: &UsageInfo) {
|
||||
self.input_tokens = self.input_tokens.saturating_add(usage.input_tokens);
|
||||
self.output_tokens = self.output_tokens.saturating_add(usage.output_tokens);
|
||||
self.cached_input_tokens = self
|
||||
.cached_input_tokens
|
||||
.saturating_add(usage.cached_input_tokens);
|
||||
if usage.charged_amount_usd > 0.0 {
|
||||
self.charged_usd += usage.charged_amount_usd;
|
||||
} else {
|
||||
self.estimated_usd += estimate_call_cost_usd(model, usage);
|
||||
}
|
||||
self.call_count = self.call_count.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Best-available USD figure: authoritative charged amount plus
|
||||
/// estimated cost for any calls that didn't carry one.
|
||||
pub fn total_usd(&self) -> f64 {
|
||||
self.charged_usd + self.estimated_usd
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn usage(input: u64, output: u64, cached: u64, charged: f64) -> UsageInfo {
|
||||
UsageInfo {
|
||||
input_tokens: input,
|
||||
output_tokens: output,
|
||||
cached_input_tokens: cached,
|
||||
charged_amount_usd: charged,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_pricing_matches_canonical_tiers() {
|
||||
assert_eq!(lookup_pricing("reasoning-v1").input_per_mtok_usd, 15.0);
|
||||
assert_eq!(lookup_pricing("agentic-v1").output_per_mtok_usd, 15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_pricing_falls_back_for_unknown_model() {
|
||||
let p = lookup_pricing("totally-unknown-model");
|
||||
assert_eq!(p.model, "<fallback>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_pricing_handles_concrete_vendor_names() {
|
||||
assert_eq!(lookup_pricing("claude-opus-4.7").input_per_mtok_usd, 15.0);
|
||||
assert_eq!(
|
||||
lookup_pricing("claude-sonnet-4-6").output_per_mtok_usd,
|
||||
15.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_pricing_routes_coding_to_coding_row_not_agentic() {
|
||||
// Pinned per CodeRabbit feedback: when the coding-tier row
|
||||
// diverges from agentic, "coding" model strings must hit
|
||||
// PRICING_TABLE[2], not [1].
|
||||
assert_eq!(lookup_pricing("coding-v1").model, "coding-v1");
|
||||
assert_eq!(lookup_pricing("agentic-v1").model, "agentic-v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_call_cost_subtracts_cached_input() {
|
||||
// 1M standard input + 1M cached input + 1M output on agentic-v1.
|
||||
let u = usage(2_000_000, 1_000_000, 1_000_000, 0.0);
|
||||
let est = estimate_call_cost_usd("agentic-v1", &u);
|
||||
// 1M * 3 + 1M * 0.3 + 1M * 15 = 18.3
|
||||
assert!((est - 18.3).abs() < 1e-6, "got {est}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_cost_prefers_charged_when_present() {
|
||||
let u = usage(100_000, 200_000, 0, 0.42);
|
||||
assert_eq!(call_cost_usd("reasoning-v1", &u), 0.42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_cost_falls_back_to_estimate_when_charged_zero() {
|
||||
let u = usage(1_000_000, 0, 0, 0.0);
|
||||
// 1M input * 3 = 3
|
||||
assert!((call_cost_usd("agentic-v1", &u) - 3.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_cost_accumulates_charged_and_estimated_separately() {
|
||||
let mut tc = TurnCost::new();
|
||||
tc.add_call("reasoning-v1", &usage(0, 0, 0, 0.10));
|
||||
tc.add_call("agentic-v1", &usage(1_000_000, 0, 0, 0.0)); // est: 3.00
|
||||
assert_eq!(tc.call_count, 2);
|
||||
assert!((tc.charged_usd - 0.10).abs() < 1e-6);
|
||||
assert!((tc.estimated_usd - 3.0).abs() < 1e-6);
|
||||
assert!((tc.total_usd() - 3.10).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_cost_aggregates_token_counts() {
|
||||
let mut tc = TurnCost::new();
|
||||
tc.add_call("agentic-v1", &usage(100, 50, 20, 0.0));
|
||||
tc.add_call("agentic-v1", &usage(200, 75, 0, 0.0));
|
||||
assert_eq!(tc.input_tokens, 300);
|
||||
assert_eq!(tc.output_tokens, 125);
|
||||
assert_eq!(tc.cached_input_tokens, 20);
|
||||
}
|
||||
}
|
||||
@@ -401,8 +401,8 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result<Dum
|
||||
|
||||
// Mirror the runner's text-mode mutation: when integrations_agent
|
||||
// has any tools the runner appends `build_text_mode_tool_instructions`
|
||||
// to the system message (see `subagent_runner::run_fork_mode` /
|
||||
// `run_typed_mode`, `force_text_mode` branch). Reproduce it here so
|
||||
// to the system message (see `subagent_runner::run_typed_mode`,
|
||||
// `force_text_mode` branch). Reproduce it here so
|
||||
// the dump matches what the LLM actually receives on turn 1.
|
||||
if !rendered_tools.is_empty() {
|
||||
let tool_specs: Vec<ToolSpec> = rendered_tools
|
||||
|
||||
@@ -3,16 +3,12 @@
|
||||
//! The authoritative list of built-in agents lives in
|
||||
//! [`crate::openhuman::agent::agents`] — each agent is a subfolder
|
||||
//! containing `agent.toml` + `prompt.md`. This module is a thin
|
||||
//! wrapper that loads that set and appends the synthetic `fork`
|
||||
//! definition (used for byte-exact prompt-cache reuse by the sub-agent
|
||||
//! runner).
|
||||
//! wrapper that loads that set.
|
||||
//!
|
||||
//! Custom TOML definitions loaded later by
|
||||
//! [`super::definition_loader`] override any built-in with the same id.
|
||||
|
||||
use super::definition::{
|
||||
AgentDefinition, DefinitionSource, ModelSpec, PromptSource, SandboxMode, ToolScope,
|
||||
};
|
||||
use super::definition::{AgentDefinition, DefinitionSource};
|
||||
|
||||
/// All built-in definitions, in stable order.
|
||||
///
|
||||
@@ -22,58 +18,8 @@ use super::definition::{
|
||||
/// [`crate::openhuman::agent::agents`] verify in CI that every entry in
|
||||
/// [`crate::openhuman::agent::agents::BUILTINS`] still parses cleanly.
|
||||
pub fn all() -> Vec<AgentDefinition> {
|
||||
let mut out = crate::openhuman::agent::agents::load_builtins()
|
||||
.expect("built-in agent TOML must always parse (see agents/*/agent.toml)");
|
||||
out.push(fork_definition());
|
||||
out
|
||||
}
|
||||
|
||||
/// The synthetic `fork` definition. Tells the runner to bypass normal
|
||||
/// prompt construction and replay the parent's exact rendered system
|
||||
/// prompt + tool schemas + message prefix from
|
||||
/// [`super::fork_context::ForkContext`]. The OpenAI-compatible backend's
|
||||
/// automatic prefix caching turns this into a real token win.
|
||||
pub fn fork_definition() -> AgentDefinition {
|
||||
AgentDefinition {
|
||||
id: "fork".into(),
|
||||
when_to_use: "Spawn a parallel sub-task that shares the parent's full system \
|
||||
prompt, tool set, and message history byte-for-byte. Use when \
|
||||
decomposing a task into independent parallel work streams that \
|
||||
benefit from prefix-cache reuse on the inference backend."
|
||||
.into(),
|
||||
display_name: Some("Fork".into()),
|
||||
// Prompt source is irrelevant — the runner reads from ForkContext.
|
||||
system_prompt: PromptSource::Inline(String::new()),
|
||||
// Fork preserves bytes — DO NOT strip anything from the parent's prompt.
|
||||
omit_identity: false,
|
||||
omit_memory_context: false,
|
||||
omit_safety_preamble: false,
|
||||
omit_skills_catalog: false,
|
||||
// Fork preserves the parent's exact prompt bytes — mirror whatever
|
||||
// PROFILE.md injection state the parent already produced rather
|
||||
// than re-gating here.
|
||||
omit_profile: false,
|
||||
omit_memory_md: false,
|
||||
model: ModelSpec::Inherit,
|
||||
// Inherit the parent's temperature too — set to a sentinel that the
|
||||
// runner replaces with the parent's actual temp at spawn time.
|
||||
// (We use 0.7 here as a safe default for documentation; the runner
|
||||
// overrides it from `ParentExecutionContext::temperature`.)
|
||||
temperature: 0.7,
|
||||
tools: ToolScope::Wildcard,
|
||||
disallowed_tools: vec![],
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
// Fork inherits the parent's max iterations from the runtime.
|
||||
max_iterations: 15,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
background: false,
|
||||
uses_fork_context: true,
|
||||
subagents: vec![],
|
||||
delegate_name: None,
|
||||
source: DefinitionSource::Builtin,
|
||||
}
|
||||
crate::openhuman::agent::agents::load_builtins()
|
||||
.expect("built-in agent TOML must always parse (see agents/*/agent.toml)")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -83,11 +29,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_definitions_present() {
|
||||
let defs = all();
|
||||
// Every entry in `agents::BUILTINS` plus 1 synthetic `fork`.
|
||||
assert_eq!(
|
||||
defs.len(),
|
||||
crate::openhuman::agent::agents::BUILTINS.len() + 1
|
||||
);
|
||||
assert_eq!(defs.len(), crate::openhuman::agent::agents::BUILTINS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -102,22 +44,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_definition_has_uses_fork_context_true() {
|
||||
let def = fork_definition();
|
||||
assert_eq!(def.id, "fork");
|
||||
assert!(def.uses_fork_context);
|
||||
assert!(matches!(def.model, ModelSpec::Inherit));
|
||||
assert!(matches!(def.tools, ToolScope::Wildcard));
|
||||
// Fork preserves bytes — must NOT strip anything.
|
||||
assert!(!def.omit_identity);
|
||||
assert!(!def.omit_memory_context);
|
||||
assert!(!def.omit_safety_preamble);
|
||||
assert!(!def.omit_skills_catalog);
|
||||
assert!(!def.omit_profile);
|
||||
assert!(!def.omit_memory_md);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_builtin_ids_are_present() {
|
||||
let ids: Vec<String> = all().into_iter().map(|d| d.id).collect();
|
||||
@@ -131,7 +57,6 @@ mod tests {
|
||||
"critic",
|
||||
"archivist",
|
||||
"summarizer",
|
||||
"fork",
|
||||
] {
|
||||
assert!(ids.contains(&expected.to_string()), "missing {expected}");
|
||||
}
|
||||
|
||||
@@ -139,10 +139,6 @@ pub struct AgentDefinition {
|
||||
#[serde(default)]
|
||||
pub background: bool,
|
||||
|
||||
/// Internal flag for `fork` mode sub-agents.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub uses_fork_context: bool,
|
||||
|
||||
// ── delegation surface ─────────────────────────────────────────────
|
||||
/// Subagents this agent is allowed to spawn via synthesised
|
||||
/// `delegate_*` tools. Each entry expands at agent-build time into
|
||||
@@ -227,10 +223,6 @@ impl SkillsWildcard {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!b
|
||||
}
|
||||
|
||||
impl AgentDefinition {
|
||||
/// Display name with fallback to id.
|
||||
pub fn display_name(&self) -> &str {
|
||||
|
||||
@@ -233,7 +233,6 @@ hint = "agentic"
|
||||
);
|
||||
assert!(reg.get("notion_specialist").is_some());
|
||||
assert!(reg.get("code_executor").is_some());
|
||||
assert!(reg.get("fork").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,7 +22,6 @@ fn make_def(id: &str) -> AgentDefinition {
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
background: false,
|
||||
uses_fork_context: false,
|
||||
subagents: vec![],
|
||||
delegate_name: None,
|
||||
source: DefinitionSource::Builtin,
|
||||
|
||||
@@ -2,25 +2,18 @@
|
||||
//! agent's runtime context (provider, tools, model, …) without widening
|
||||
//! the [`crate::openhuman::tools::Tool`] trait.
|
||||
//!
|
||||
//! Two distinct task-locals live here:
|
||||
//! [`PARENT_CONTEXT`] is set by the parent
|
||||
//! [`crate::openhuman::agent::Agent`] around its `turn` so that any tool
|
||||
//! executing inside that turn (in particular `spawn_subagent`) can read
|
||||
//! the parent's provider, tool list, and model information.
|
||||
//!
|
||||
//! 1. [`PARENT_CONTEXT`] — set by the parent [`crate::openhuman::agent::Agent`]
|
||||
//! around its `turn` so that any tool executing inside that turn (in
|
||||
//! particular `spawn_subagent`) can read the parent's provider, tool
|
||||
//! list, and model information.
|
||||
//!
|
||||
//! 2. [`FORK_CONTEXT`] — set only when the parent dispatches a `fork`-mode
|
||||
//! sub-agent. Carries the parent's *exact* rendered system prompt, tool
|
||||
//! schemas, and message prefix so the forked child can replay the same
|
||||
//! bytes and the inference backend's automatic prefix caching kicks in.
|
||||
//!
|
||||
//! Both contexts are stashed in `Arc`s so that cloning into the child
|
||||
//! costs a refcount bump rather than a full copy.
|
||||
//! Stashed in `Arc`s so cloning into a child costs a refcount bump
|
||||
//! rather than a full copy.
|
||||
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::config::AgentConfig;
|
||||
use crate::openhuman::memory::Memory;
|
||||
use crate::openhuman::providers::{ChatMessage, Provider};
|
||||
use crate::openhuman::providers::Provider;
|
||||
use crate::openhuman::skills::Skill;
|
||||
use crate::openhuman::tools::{Tool, ToolSpec};
|
||||
use std::path::PathBuf;
|
||||
@@ -152,61 +145,3 @@ where
|
||||
{
|
||||
PARENT_CONTEXT.scope(ctx, future).await
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fork context
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Captures the parent's exact rendered prompt + tool schemas + message
|
||||
/// prefix so a forked sub-agent can replay them byte-for-byte.
|
||||
///
|
||||
/// **Why this matters**: OpenAI-compatible inference backends apply
|
||||
/// automatic prefix caching server-side based on stable byte sequences.
|
||||
/// If the forked child's request shares an identical prefix with the
|
||||
/// parent's previous request, the prefix is served from cache and only
|
||||
/// the diverging tail is billed. Forking this way is the biggest
|
||||
/// token-saving mechanism OpenHuman has for parallel sub-agent work.
|
||||
///
|
||||
/// To preserve byte stability we hold:
|
||||
/// - `system_prompt` as a pre-rendered `String` (not the builder).
|
||||
/// - `tool_specs` as already-serialised `ToolSpec` values.
|
||||
/// - `message_prefix` as the parent's `ChatMessage` history *up to and
|
||||
/// including* the assistant message that issued the `spawn_subagent`
|
||||
/// tool call.
|
||||
#[derive(Clone)]
|
||||
pub struct ForkContext {
|
||||
/// Parent's rendered system prompt. Becomes message[0] of the child.
|
||||
pub system_prompt: Arc<String>,
|
||||
|
||||
/// Parent's tool schemas. The child's `ChatRequest.tools` borrows from
|
||||
/// this slice unchanged.
|
||||
pub tool_specs: Arc<Vec<ToolSpec>>,
|
||||
|
||||
/// Parent's message history prefix that the child should replay
|
||||
/// verbatim. Includes the system message at index 0.
|
||||
pub message_prefix: Arc<Vec<ChatMessage>>,
|
||||
|
||||
/// The actual instruction the model issued for *this* fork — appears
|
||||
/// as the new user message appended after `message_prefix`.
|
||||
pub fork_task_prompt: String,
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
/// Fork context, scoped per `spawn_subagent { mode: "fork", … }`
|
||||
/// invocation. The runner reads it when the requested definition has
|
||||
/// `uses_fork_context = true`.
|
||||
pub static FORK_CONTEXT: ForkContext;
|
||||
}
|
||||
|
||||
/// Returns a clone of the current fork context, if one is set.
|
||||
pub fn current_fork() -> Option<ForkContext> {
|
||||
FORK_CONTEXT.try_with(|ctx| ctx.clone()).ok()
|
||||
}
|
||||
|
||||
/// Run `future` with `ctx` installed as the active fork context.
|
||||
pub async fn with_fork_context<F, R>(ctx: ForkContext, future: F) -> R
|
||||
where
|
||||
F: std::future::Future<Output = R>,
|
||||
{
|
||||
FORK_CONTEXT.scope(ctx, future).await
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Multi-agent harness — sub-agent dispatch and fork-cache support.
|
||||
//! Multi-agent harness — sub-agent dispatch and parent-context plumbing.
|
||||
//!
|
||||
//! The harness provides the infrastructure for an agent to delegate work to
|
||||
//! specialized sub-agents. It manages the lifecycle of these sub-agents,
|
||||
@@ -10,10 +10,8 @@
|
||||
//! in the global [`AgentDefinitionRegistry`] and runs a dedicated tool loop.
|
||||
//!
|
||||
//! ## Token Optimization
|
||||
//! - **Typed Sub-agents**: Skips unnecessary system prompt sections (e.g.,
|
||||
//! - **Typed Sub-agents**: Skip unnecessary system prompt sections (e.g.,
|
||||
//! identity, global skills) to keep sub-agent prompts small.
|
||||
//! - **Fork Mode**: Allows sub-agents to replay the parent's exact context
|
||||
//! to leverage KV-cache reuse on the inference backend.
|
||||
//!
|
||||
//! ## Key Sub-modules
|
||||
//! - **[`subagent_runner`]**: The core logic for executing a sub-agent.
|
||||
@@ -44,10 +42,7 @@ pub use definition::{
|
||||
AgentDefinition, AgentDefinitionRegistry, DefinitionSource, ModelSpec, PromptSource,
|
||||
SandboxMode, ToolScope,
|
||||
};
|
||||
pub use fork_context::{
|
||||
current_fork, current_parent, with_fork_context, with_parent_context, ForkContext,
|
||||
ParentExecutionContext,
|
||||
};
|
||||
pub use fork_context::{current_parent, with_parent_context, ParentExecutionContext};
|
||||
pub use interrupt::{check_interrupt, InterruptFence, InterruptedError};
|
||||
pub use sandbox_context::{current_sandbox_mode, with_current_sandbox_mode};
|
||||
pub use subagent_runner::{run_subagent, SubagentRunError, SubagentRunOptions};
|
||||
|
||||
@@ -362,7 +362,6 @@ mod tests {
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
background: false,
|
||||
uses_fork_context: false,
|
||||
subagents: vec![],
|
||||
delegate_name: None,
|
||||
source: DefinitionSource::Builtin,
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
//! implementations can enforce sandbox semantics at execution time without
|
||||
//! widening the [`crate::openhuman::tools::Tool`] trait signature.
|
||||
//!
|
||||
//! Sibling of the existing [`super::fork_context`] task-locals but serves
|
||||
//! a different concept: `PARENT_CONTEXT` / `FORK_CONTEXT` carry the
|
||||
//! *parent agent's* runtime context so that `spawn_subagent` can inherit
|
||||
//! it, whereas [`CURRENT_AGENT_SANDBOX_MODE`] carries the *currently-
|
||||
//! executing agent's* sandbox mode so that any tool it invokes can gate
|
||||
//! on that mode.
|
||||
//! Sibling of the existing [`super::fork_context`] task-local but serves
|
||||
//! a different concept: `PARENT_CONTEXT` carries the *parent agent's*
|
||||
//! runtime context so that `spawn_subagent` can inherit it, whereas
|
||||
//! [`CURRENT_AGENT_SANDBOX_MODE`] carries the *currently-executing
|
||||
//! agent's* sandbox mode so that any tool it invokes can gate on that
|
||||
//! mode.
|
||||
//!
|
||||
//! Why a task-local instead of an argument on [`Tool::execute`]: the tool
|
||||
//! trait is called from many places (CLI, JSON-RPC, tests, agent loops).
|
||||
|
||||
@@ -480,114 +480,6 @@ async fn turn_dispatches_spawn_subagent_through_full_path() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Fork-mode variant of `turn_dispatches_spawn_subagent_through_full_path`.
|
||||
///
|
||||
/// Exercises the prefix-replay path: the parent issues
|
||||
/// `spawn_subagent { mode: "fork", … }`, the runner resolves the `fork`
|
||||
/// built-in definition, pulls the parent's exact rendered prompt + tool
|
||||
/// schemas + message prefix out of the `ForkContext` task-local, and
|
||||
/// runs the inner loop on the parent's own provider.
|
||||
///
|
||||
/// From the provider's perspective the response queue is consumed in
|
||||
/// the same fixed sequence as the typed test — parent tool_call → sub-
|
||||
/// agent reply → parent folded reply — which is the invariant that
|
||||
/// makes KV-cache reuse possible on the real backend.
|
||||
#[tokio::test]
|
||||
async fn turn_dispatches_spawn_subagent_in_fork_mode() {
|
||||
use crate::openhuman::agent::harness::AgentDefinitionRegistry;
|
||||
use crate::openhuman::tools::SpawnSubagentTool;
|
||||
|
||||
// Idempotent — other tests may have already initialised it.
|
||||
AgentDefinitionRegistry::init_global_builtins().unwrap();
|
||||
|
||||
let workspace = tempfile::TempDir::new().expect("temp workspace");
|
||||
let workspace_path = workspace.path().to_path_buf();
|
||||
|
||||
// Provider response queue, consumed in order:
|
||||
// 1. Parent turn iter 0 — emit spawn_subagent with mode=fork.
|
||||
// 2. Fork sub-agent iter 0 — return "X is Y" (no tool calls).
|
||||
// 3. Parent turn iter 1 — fold the forked result into the final
|
||||
// text the user sees.
|
||||
let provider = Box::new(MockProvider {
|
||||
responses: Mutex::new(vec![
|
||||
crate::openhuman::providers::ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: vec![crate::openhuman::providers::ToolCall {
|
||||
id: "call-fork".into(),
|
||||
name: "spawn_subagent".into(),
|
||||
arguments: serde_json::json!({
|
||||
// agent_id is still required by the schema even
|
||||
// though `mode=fork` overrides the lookup to the
|
||||
// synthetic `fork` definition.
|
||||
"agent_id": "researcher",
|
||||
"mode": "fork",
|
||||
"prompt": "analyse branch X"
|
||||
})
|
||||
.to_string(),
|
||||
}],
|
||||
usage: None,
|
||||
},
|
||||
crate::openhuman::providers::ChatResponse {
|
||||
text: Some("X is Y".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
},
|
||||
crate::openhuman::providers::ChatResponse {
|
||||
text: Some("Based on the research, X is Y.".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
let memory_cfg = crate::openhuman::config::MemoryConfig {
|
||||
backend: "none".into(),
|
||||
..crate::openhuman::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path).unwrap());
|
||||
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(SpawnSubagentTool::new())];
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(tools)
|
||||
.memory(mem)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.workspace_dir(workspace_path)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let response = agent.turn("tell me about X").await.unwrap();
|
||||
assert_eq!(response, "Based on the research, X is Y.");
|
||||
|
||||
// Same history assertions as the typed path — the fork runner
|
||||
// still threads its compact output back through the parent's tool
|
||||
// result pipeline.
|
||||
let has_spawn_call = agent.history().iter().any(|msg| match msg {
|
||||
ConversationMessage::AssistantToolCalls { tool_calls, .. } => {
|
||||
tool_calls.iter().any(|c| c.name == "spawn_subagent")
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
assert!(
|
||||
has_spawn_call,
|
||||
"parent history should contain the spawn_subagent assistant tool call"
|
||||
);
|
||||
|
||||
let tool_result_contains_subagent_output = agent.history().iter().any(|msg| match msg {
|
||||
ConversationMessage::ToolResults(results) => {
|
||||
results.iter().any(|r| r.content.contains("X is Y"))
|
||||
}
|
||||
ConversationMessage::Chat(chat) if chat.role == "tool" => chat.content.contains("X is Y"),
|
||||
_ => false,
|
||||
});
|
||||
assert!(
|
||||
tool_result_contains_subagent_output,
|
||||
"parent history should contain a tool-result entry with the fork sub-agent's output"
|
||||
);
|
||||
}
|
||||
|
||||
/// KV-cache invariant: across multiple turns in the same session, the
|
||||
/// system-prompt bytes submitted to the provider must be byte-identical,
|
||||
/// and the model name must not flip. Both are required for the backend's
|
||||
|
||||
@@ -8,11 +8,9 @@
|
||||
//! the context pipeline (tool-result budget → microcompact →
|
||||
//! autocompact signal → session-memory extraction trigger).
|
||||
//! - [`Agent::execute_tool_call`] / [`Agent::execute_tools`] — the
|
||||
//! per-call runners, including the fork-cache `ForkContext` stash
|
||||
//! for `spawn_subagent { mode: "fork" }` invocations.
|
||||
//! - [`Agent::build_parent_execution_context`] /
|
||||
//! [`Agent::build_fork_context`] — snapshot helpers for sub-agent
|
||||
//! task-locals.
|
||||
//! per-call runners.
|
||||
//! - [`Agent::build_parent_execution_context`] — snapshot helper for
|
||||
//! the parent-context task-local that sub-agents read.
|
||||
//! - [`Agent::trim_history`], [`Agent::fetch_learned_context`],
|
||||
//! [`Agent::build_system_prompt`] — the small helpers `turn()` leans
|
||||
//! on every call.
|
||||
@@ -862,25 +860,6 @@ impl Agent {
|
||||
log::info!("[agent] executing tool: {}", call.name);
|
||||
log::info!("[agent_loop] tool start name={}", call.name);
|
||||
|
||||
// Special-case `spawn_subagent { mode: "fork", … }`: stash a
|
||||
// ForkContext task-local so the sub-agent runner can replay the
|
||||
// parent's exact rendered prompt + tool schemas + message prefix
|
||||
// for backend prefix-cache reuse. The branch is taken before
|
||||
// executing the tool so the task-local is visible inside
|
||||
// `tool.execute(...)`.
|
||||
let fork_context_for_call = if call.name == "spawn_subagent"
|
||||
&& call
|
||||
.arguments
|
||||
.get("mode")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.eq_ignore_ascii_case("fork"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Some(self.build_fork_context(call))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (raw_result, success) = if !self.visible_tool_names.is_empty()
|
||||
&& !self.visible_tool_names.contains(&call.name)
|
||||
{
|
||||
@@ -900,12 +879,9 @@ impl Agent {
|
||||
// implementation which forwards to `execute`.
|
||||
let prefer_markdown = self.context.prefer_markdown_tool_output();
|
||||
let options = ToolCallOptions { prefer_markdown };
|
||||
let exec = tool.execute_with_options(call.arguments.clone(), options);
|
||||
let outcome = if let Some(fork_ctx) = fork_context_for_call {
|
||||
harness::with_fork_context(fork_ctx, exec).await
|
||||
} else {
|
||||
exec.await
|
||||
};
|
||||
let outcome = tool
|
||||
.execute_with_options(call.arguments.clone(), options)
|
||||
.await;
|
||||
match outcome {
|
||||
Ok(r) => {
|
||||
if !r.is_error {
|
||||
@@ -1094,39 +1070,6 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`harness::ForkContext`] capturing the parent's
|
||||
/// rendered system prompt + tool schemas + message prefix at the
|
||||
/// moment a `spawn_subagent { mode: "fork", … }` call fires.
|
||||
///
|
||||
/// The system prompt is pulled from `history[0]` (the agent always
|
||||
/// stores its rendered system prompt as the first message). The
|
||||
/// message prefix is the entire current history rendered through
|
||||
/// the dispatcher — the *same* sequence the parent's next call
|
||||
/// would send, except the new fork directive replaces the parent's
|
||||
/// next continuation.
|
||||
pub(super) fn build_fork_context(&self, call: &ParsedToolCall) -> harness::ForkContext {
|
||||
let messages = self.tool_dispatcher.to_provider_messages(&self.history);
|
||||
let system_prompt: String = messages
|
||||
.first()
|
||||
.filter(|m| m.role == "system")
|
||||
.map(|m| m.content.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let fork_task_prompt = call
|
||||
.arguments
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
harness::ForkContext {
|
||||
system_prompt: Arc::new(system_prompt),
|
||||
tool_specs: Arc::clone(&self.visible_tool_specs),
|
||||
message_prefix: Arc::new(messages),
|
||||
fork_task_prompt,
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// History & prompt helpers
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -234,24 +234,6 @@ fn trim_history_preserves_system_and_keeps_latest_non_system_entries() {
|
||||
.any(|msg| matches!(msg, ConversationMessage::Chat(chat) if chat.content == "a2")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_fork_context_uses_visible_specs_and_prompt_argument() {
|
||||
let mut visible = HashSet::new();
|
||||
visible.insert("echo".to_string());
|
||||
let agent = make_agent(Some(visible));
|
||||
let call = ParsedToolCall {
|
||||
name: "spawn_subagent".into(),
|
||||
arguments: serde_json::json!({ "prompt": "fork task" }),
|
||||
tool_call_id: None,
|
||||
};
|
||||
|
||||
let fork = agent.build_fork_context(&call);
|
||||
assert_eq!(fork.fork_task_prompt, "fork task");
|
||||
assert_eq!(fork.tool_specs.len(), 1);
|
||||
assert_eq!(fork.tool_specs[0].name, "echo");
|
||||
assert_eq!(fork.message_prefix.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() {
|
||||
let mut agent = make_agent(None);
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
//! Sub-agent execution entry points and the inner tool-call loop.
|
||||
//!
|
||||
//! The public runner lives in [`run_subagent`]. It dispatches to
|
||||
//! [`run_typed_mode`] (narrow prompt + filtered tools) or
|
||||
//! [`run_fork_mode`] (prefix-replay) depending on the
|
||||
//! [`super::types::SubagentMode`] implied by the
|
||||
//! [`crate::openhuman::agent::harness::definition::AgentDefinition`].
|
||||
//!
|
||||
//! Both modes delegate to [`run_inner_loop`] which drives provider
|
||||
//! calls and tool execution until the model returns without further
|
||||
//! tool calls (or the iteration budget is exhausted).
|
||||
//! [`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).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::super::fork_context::{
|
||||
current_fork, current_parent, ForkContext, ParentExecutionContext,
|
||||
};
|
||||
use super::super::fork_context::{current_parent, ParentExecutionContext};
|
||||
use super::super::session::transcript;
|
||||
use super::extract_tool::ExtractFromResultTool;
|
||||
use super::handoff::{
|
||||
@@ -74,7 +69,7 @@ impl LazyToolkitResolver {
|
||||
/// This is the primary entry point for agent delegation. It performs the following:
|
||||
/// 1. Resolves the [`ParentExecutionContext`] task-local.
|
||||
/// 2. Generates a unique `task_id` if one wasn't provided.
|
||||
/// 3. Dispatches to either `run_fork_mode` or `run_typed_mode` based on the definition.
|
||||
/// 3. Dispatches to `run_typed_mode`.
|
||||
///
|
||||
/// On success returns a [`SubagentRunOutcome`] whose `output` is the
|
||||
/// final assistant text. On failure the error is suitable for stringifying
|
||||
@@ -105,12 +100,7 @@ pub async fn run_subagent(
|
||||
// Write/Admin slugs under `ReadOnly`) read it via
|
||||
// `current_sandbox_mode()`; tools that don't care just ignore it.
|
||||
let outcome = with_current_sandbox_mode(definition.sandbox_mode, async {
|
||||
if definition.uses_fork_context {
|
||||
let fork = current_fork().ok_or(SubagentRunError::NoForkContext)?;
|
||||
run_fork_mode(definition, task_prompt, &options, &parent, &fork, &task_id).await
|
||||
} else {
|
||||
run_typed_mode(definition, task_prompt, &options, &parent, &task_id).await
|
||||
}
|
||||
run_typed_mode(definition, task_prompt, &options, &parent, &task_id).await
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -755,112 +745,6 @@ async fn run_typed_mode(
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fork mode — replay parent's bytes for prefix-cache reuse
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Execute a sub-agent in "Fork" mode.
|
||||
///
|
||||
/// This mode is an optimization. It replays the parent's EXACT rendered prompt
|
||||
/// and history prefix up to the point of delegation. This allows the inference
|
||||
/// server to reuse its existing KV-cache for the prefix, drastically reducing
|
||||
/// first-token latency and token costs for parallel delegation.
|
||||
async fn run_fork_mode(
|
||||
definition: &AgentDefinition,
|
||||
_task_prompt: &str,
|
||||
_options: &SubagentRunOptions,
|
||||
parent: &ParentExecutionContext,
|
||||
fork: &ForkContext,
|
||||
task_id: &str,
|
||||
) -> Result<SubagentRunOutcome, SubagentRunError> {
|
||||
let started = Instant::now();
|
||||
|
||||
// The fork's task prompt comes from the ForkContext (set by the
|
||||
// parent's tool-dispatch site), not from the spawn_subagent args
|
||||
// directly. This guarantees the bytes the parent committed to are
|
||||
// what the child sees.
|
||||
let fork_task_prompt = fork.fork_task_prompt.clone();
|
||||
|
||||
tracing::debug!(
|
||||
agent_id = %definition.id,
|
||||
prefix_len = fork.message_prefix.len(),
|
||||
"[subagent_runner:fork] replaying parent prefix"
|
||||
);
|
||||
|
||||
// History = parent's exact prefix (which already starts with the
|
||||
// parent's system message), then the new fork directive as a user
|
||||
// message. The system_prompt arc is unused here because the prefix
|
||||
// already contains the system message at index 0 — but we sanity-
|
||||
// check that invariant.
|
||||
debug_assert!(
|
||||
fork.message_prefix
|
||||
.first()
|
||||
.map(|m| m.role == "system")
|
||||
.unwrap_or(false),
|
||||
"fork message_prefix must start with the parent's system message"
|
||||
);
|
||||
let mut history: Vec<ChatMessage> = (*fork.message_prefix).clone();
|
||||
history.push(ChatMessage::user(fork_task_prompt));
|
||||
|
||||
// Fork mode keeps the parent's exact tool schema snapshot so the
|
||||
// request body matches the prefix the backend has already cached.
|
||||
// Runtime execution still resolves against the parent's live tool
|
||||
// registry.
|
||||
//
|
||||
// Sub-agents (including fork-mode ones) must not spawn their own
|
||||
// sub-agents — the rule that applies in `run_typed_mode`'s filter
|
||||
// applies here too. We keep `spawn_subagent` / `delegate_*` in
|
||||
// `fork.tool_specs` so the prefix bytes still match the parent's
|
||||
// cached body (mutating the specs would defeat the whole point of
|
||||
// fork mode), and instead drop them from `allowed_names` so the
|
||||
// runtime rejects any attempt to call them with the usual
|
||||
// "not in allowlist" path.
|
||||
let allowed_names: HashSet<String> = parent
|
||||
.all_tools
|
||||
.iter()
|
||||
.map(|t| t.name().to_string())
|
||||
.filter(|name| !is_subagent_spawn_tool(name) && name != "spawn_worker_thread")
|
||||
.collect();
|
||||
|
||||
let model = parent.model_name.clone();
|
||||
let temperature = parent.temperature;
|
||||
// Use the parent's iteration cap, not the synthetic fork definition's.
|
||||
let max_iterations = parent.agent_config.max_tool_iterations.max(1);
|
||||
|
||||
// Fork mode replays the parent's exact tool list — no dynamic
|
||||
// toolkit-scoped tools, so `extra_tools` is empty.
|
||||
let fork_extra_tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
// Transcript persistence happens per-iteration inside
|
||||
// `run_inner_loop`; no post-loop write needed.
|
||||
let (output, iterations, _agg_usage) = run_inner_loop(
|
||||
parent.provider.as_ref(),
|
||||
&mut history,
|
||||
&parent.all_tools,
|
||||
fork_extra_tools,
|
||||
fork.tool_specs.as_slice(),
|
||||
allowed_names,
|
||||
None,
|
||||
&model,
|
||||
temperature,
|
||||
max_iterations,
|
||||
task_id,
|
||||
&definition.id,
|
||||
None,
|
||||
None,
|
||||
parent,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(SubagentRunOutcome {
|
||||
task_id: task_id.to_string(),
|
||||
agent_id: definition.id.clone(),
|
||||
output,
|
||||
iterations,
|
||||
elapsed: started.elapsed(),
|
||||
mode: SubagentMode::Fork,
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Inner tool-call loop (slim version of agent::loop_::tool_loop)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -986,7 +870,7 @@ async fn run_inner_loop(
|
||||
if force_text_mode {
|
||||
// Append the XML tool protocol + available-tool list to the
|
||||
// existing system prompt. `history[0]` is the system message
|
||||
// built by `run_typed_mode` / `run_fork_mode` upstream; we
|
||||
// built by `run_typed_mode` upstream; we
|
||||
// augment it in-place so the model learns the call format for
|
||||
// this session without an extra message round-trip.
|
||||
if let Some(sys) = history.iter_mut().find(|m| m.role == "system") {
|
||||
|
||||
@@ -23,7 +23,6 @@ fn make_def_named_tools(names: &[&str]) -> AgentDefinition {
|
||||
timeout_secs: None,
|
||||
sandbox_mode: crate::openhuman::agent::harness::definition::SandboxMode::None,
|
||||
background: false,
|
||||
uses_fork_context: false,
|
||||
subagents: vec![],
|
||||
delegate_name: None,
|
||||
source: crate::openhuman::agent::harness::definition::DefinitionSource::Builtin,
|
||||
@@ -114,12 +113,11 @@ fn filter_skill_filter_combined_with_named_scope() {
|
||||
#[test]
|
||||
fn subagent_mode_as_str_roundtrip() {
|
||||
assert_eq!(SubagentMode::Typed.as_str(), "typed");
|
||||
assert_eq!(SubagentMode::Fork.as_str(), "fork");
|
||||
}
|
||||
|
||||
// ── End-to-end runner tests with mock provider ────────────────────────
|
||||
|
||||
use crate::openhuman::agent::harness::fork_context::{with_fork_context, with_parent_context};
|
||||
use crate::openhuman::agent::harness::fork_context::with_parent_context;
|
||||
use crate::openhuman::providers::{ChatRequest as PChatRequest, ChatResponse, Provider, ToolCall};
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
@@ -536,76 +534,6 @@ async fn typed_mode_blocks_unallowed_tool_calls() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fork_mode_replays_parent_prefix_bytes() {
|
||||
// Construct a fake fork context with a known message prefix.
|
||||
// The runner should replay it byte-for-byte plus a single
|
||||
// appended user message carrying the fork directive.
|
||||
let provider = ScriptedProvider::new(vec![text_response("fork done")]);
|
||||
let parent = make_parent(provider.clone(), vec![stub("file_read"), stub("shell")]);
|
||||
|
||||
let prefix = vec![
|
||||
crate::openhuman::providers::ChatMessage::system("PARENT_SYSTEM_PROMPT_BYTES"),
|
||||
crate::openhuman::providers::ChatMessage::user("first user msg"),
|
||||
crate::openhuman::providers::ChatMessage::assistant("parent assistant"),
|
||||
];
|
||||
|
||||
let fork = ForkContext {
|
||||
system_prompt: Arc::new("PARENT_SYSTEM_PROMPT_BYTES".into()),
|
||||
tool_specs: Arc::new(vec![parent.all_tool_specs[0].clone()]),
|
||||
message_prefix: Arc::new(prefix.clone()),
|
||||
fork_task_prompt: "ANALYSE THIS BRANCH".into(),
|
||||
};
|
||||
|
||||
let def = crate::openhuman::agent::harness::builtin_definitions::fork_definition();
|
||||
|
||||
let outcome = with_parent_context(parent, async move {
|
||||
with_fork_context(fork, async {
|
||||
run_subagent(
|
||||
&def,
|
||||
"ignored — fork uses fork_task_prompt",
|
||||
SubagentRunOptions::default(),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.expect("fork runner should succeed");
|
||||
|
||||
assert_eq!(outcome.mode, SubagentMode::Fork);
|
||||
assert_eq!(outcome.output, "fork done");
|
||||
|
||||
// Verify the request that hit the provider replays the parent
|
||||
// prefix exactly and appends only the fork directive.
|
||||
let captured = provider.captured.lock();
|
||||
let first_call = &captured[0];
|
||||
assert_eq!(first_call.messages.len(), prefix.len() + 1);
|
||||
for (i, msg) in prefix.iter().enumerate() {
|
||||
assert_eq!(first_call.messages[i].role, msg.role);
|
||||
assert_eq!(first_call.messages[i].content, msg.content);
|
||||
}
|
||||
// The appended user message carries the fork directive.
|
||||
let appended = first_call.messages.last().unwrap();
|
||||
assert_eq!(appended.role, "user");
|
||||
assert_eq!(appended.content, "ANALYSE THIS BRANCH");
|
||||
assert_eq!(first_call.tool_count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fork_mode_errors_when_no_fork_context() {
|
||||
let provider = ScriptedProvider::new(vec![text_response("unused")]);
|
||||
let parent = make_parent(provider, vec![stub("file_read")]);
|
||||
let def = crate::openhuman::agent::harness::builtin_definitions::fork_definition();
|
||||
|
||||
let result = with_parent_context(parent, async {
|
||||
run_subagent(&def, "x", SubagentRunOptions::default()).await
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(SubagentRunError::NoForkContext)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runner_errors_outside_parent_context() {
|
||||
let def = make_def_named_tools(&[]);
|
||||
|
||||
@@ -56,19 +56,21 @@ pub struct SubagentRunOutcome {
|
||||
}
|
||||
|
||||
/// Which prompt-construction path the runner took for a sub-agent.
|
||||
///
|
||||
/// Currently the only supported mode is `Typed` (narrow, archetype-specific
|
||||
/// prompt with filtered tools). Kept as an enum so future modes (e.g.
|
||||
/// background/swarm) can land without churning every call site that records
|
||||
/// the mode for telemetry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SubagentMode {
|
||||
/// Built a narrow, archetype-specific prompt with filtered tools.
|
||||
Typed,
|
||||
/// Replayed the parent's exact rendered prompt and history prefix.
|
||||
Fork,
|
||||
}
|
||||
|
||||
impl SubagentMode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Typed => "typed",
|
||||
Self::Fork => "fork",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,12 +82,6 @@ pub enum SubagentRunError {
|
||||
#[error("spawn_subagent called outside of an agent turn — no parent context available")]
|
||||
NoParentContext,
|
||||
|
||||
#[error(
|
||||
"fork-mode sub-agent requested but no ForkContext is set on the task-local. \
|
||||
Did the parent agent forget to call `Agent::turn` with fork support?"
|
||||
)]
|
||||
NoForkContext,
|
||||
|
||||
#[error("agent definition '{0}' not found in registry")]
|
||||
DefinitionNotFound(String),
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::openhuman::agent::cost::TurnCost;
|
||||
use crate::openhuman::agent::multimodal;
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::agent::stop_hooks::{current_stop_hooks, StopDecision, TurnState};
|
||||
use crate::openhuman::approval::{ApprovalManager, ApprovalRequest, ApprovalResponse};
|
||||
use crate::openhuman::providers::{
|
||||
ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ProviderDelta,
|
||||
@@ -151,6 +153,7 @@ pub(crate) async fn run_tool_call_loop(
|
||||
);
|
||||
|
||||
let mut context_guard = ContextGuard::new();
|
||||
let mut turn_cost = TurnCost::new();
|
||||
|
||||
// Announce turn start to progress subscribers (if any). We use
|
||||
// `send().await` for lifecycle (turn/iteration) events so they
|
||||
@@ -163,6 +166,7 @@ pub(crate) async fn run_tool_call_loop(
|
||||
}
|
||||
}
|
||||
|
||||
let stop_hooks = current_stop_hooks();
|
||||
for iteration in 0..max_iterations {
|
||||
if let Some(ref sink) = on_progress {
|
||||
if let Err(e) = sink
|
||||
@@ -175,6 +179,31 @@ pub(crate) async fn run_tool_call_loop(
|
||||
log::warn!("[agent_loop] progress sink closed at IterationStarted: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stop hooks: policy check before the next LLM call ──
|
||||
if !stop_hooks.is_empty() {
|
||||
let state = TurnState {
|
||||
iteration: (iteration + 1) as u32,
|
||||
max_iterations: max_iterations as u32,
|
||||
cost: &turn_cost,
|
||||
model,
|
||||
};
|
||||
for hook in &stop_hooks {
|
||||
match hook.check(&state).await {
|
||||
StopDecision::Continue => {}
|
||||
StopDecision::Stop { reason } => {
|
||||
tracing::warn!(
|
||||
iteration = (iteration + 1),
|
||||
hook = hook.name(),
|
||||
reason = %reason,
|
||||
"[agent_loop] stop hook triggered — aborting turn"
|
||||
);
|
||||
anyhow::bail!("Agent turn stopped by hook '{}': {reason}", hook.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context guard: check utilization before each LLM call ──
|
||||
match context_guard.check() {
|
||||
ContextCheckResult::Ok => {}
|
||||
@@ -311,13 +340,30 @@ pub(crate) async fn run_tool_call_loop(
|
||||
// Update context guard with token usage from this response.
|
||||
if let Some(ref usage) = resp.usage {
|
||||
context_guard.update_usage(usage);
|
||||
turn_cost.add_call(model, usage);
|
||||
tracing::debug!(
|
||||
iteration,
|
||||
input_tokens = usage.input_tokens,
|
||||
output_tokens = usage.output_tokens,
|
||||
context_window = usage.context_window,
|
||||
cumulative_usd = turn_cost.total_usd(),
|
||||
"[agent_loop] LLM response received"
|
||||
);
|
||||
if let Some(ref sink) = on_progress {
|
||||
let event = AgentProgress::TurnCostUpdated {
|
||||
model: model.to_string(),
|
||||
iteration: (iteration + 1) as u32,
|
||||
input_tokens: turn_cost.input_tokens,
|
||||
output_tokens: turn_cost.output_tokens,
|
||||
cached_input_tokens: turn_cost.cached_input_tokens,
|
||||
total_usd: turn_cost.total_usd(),
|
||||
};
|
||||
if let Err(e) = sink.send(event).await {
|
||||
log::warn!(
|
||||
"[agent_loop] progress sink closed at TurnCostUpdated: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
iteration,
|
||||
@@ -407,6 +453,15 @@ pub(crate) async fn run_tool_call_loop(
|
||||
}
|
||||
}
|
||||
history.push(ChatMessage::assistant(response_text.clone()));
|
||||
log::info!(
|
||||
"[agent_loop] turn complete: iters={} provider_calls={} tokens_in={} tokens_out={} cached_in={} usd={:.4}",
|
||||
(iteration + 1),
|
||||
turn_cost.call_count,
|
||||
turn_cost.input_tokens,
|
||||
turn_cost.output_tokens,
|
||||
turn_cost.cached_input_tokens,
|
||||
turn_cost.total_usd(),
|
||||
);
|
||||
if let Some(ref sink) = on_progress {
|
||||
if let Err(e) = sink
|
||||
.send(AgentProgress::TurnCompleted {
|
||||
@@ -599,38 +654,69 @@ pub(crate) async fn run_tool_call_loop(
|
||||
);
|
||||
scrubbed = compacted;
|
||||
}
|
||||
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
|
||||
);
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,3 +667,222 @@ async fn run_tool_call_loop_propagates_provider_errors_and_max_iteration_failure
|
||||
.to_string()
|
||||
.contains("Agent exceeded maximum tool iterations (1)"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_aborts_when_stop_hook_returns_stop() {
|
||||
use crate::openhuman::agent::stop_hooks::{with_stop_hooks, StopDecision, StopHook, TurnState};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Stops the loop on the second iteration (1-based).
|
||||
struct StopOnIteration(Arc<AtomicU32>);
|
||||
|
||||
#[async_trait]
|
||||
impl StopHook for StopOnIteration {
|
||||
fn name(&self) -> &str {
|
||||
"test-iter-cap"
|
||||
}
|
||||
async fn check(&self, ctx: &TurnState<'_>) -> StopDecision {
|
||||
self.0.store(ctx.iteration, Ordering::Relaxed);
|
||||
if ctx.iteration >= 2 {
|
||||
StopDecision::Stop {
|
||||
reason: "tripped on iter 2".into(),
|
||||
}
|
||||
} else {
|
||||
StopDecision::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Provider would happily loop forever — first response asks for a
|
||||
// tool, second response would too (we never reach it because the
|
||||
// stop hook fires at the top of iteration 2).
|
||||
let provider = ScriptedProvider {
|
||||
responses: Mutex::new(vec![
|
||||
Ok(ChatResponse {
|
||||
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
}),
|
||||
Ok(ChatResponse {
|
||||
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
}),
|
||||
]),
|
||||
native_tools: false,
|
||||
vision: false,
|
||||
};
|
||||
let mut history = vec![ChatMessage::user("loop me")];
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
|
||||
let last_seen = Arc::new(AtomicU32::new(0));
|
||||
let hook: Arc<dyn StopHook> = Arc::new(StopOnIteration(last_seen.clone()));
|
||||
|
||||
let err = with_stop_hooks(vec![hook], async {
|
||||
run_tool_call_loop(
|
||||
&provider,
|
||||
&mut history,
|
||||
&tools,
|
||||
"test-provider",
|
||||
"model",
|
||||
0.0,
|
||||
true,
|
||||
None,
|
||||
"channel",
|
||||
&crate::openhuman::config::MultimodalConfig::default(),
|
||||
10,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.expect_err("stop hook should abort the loop");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("stopped by hook 'test-iter-cap'"),
|
||||
"got: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("tripped on iter 2"),
|
||||
"stop reason should be propagated, got: {err}"
|
||||
);
|
||||
assert_eq!(
|
||||
last_seen.load(Ordering::Relaxed),
|
||||
2,
|
||||
"hook should have observed iteration 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_runs_unchanged_when_no_stop_hooks_installed() {
|
||||
// Sanity: with no `with_stop_hooks` scope, the loop behaves
|
||||
// identically to before this feature landed.
|
||||
let provider = ScriptedProvider {
|
||||
responses: Mutex::new(vec![Ok(ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
})]),
|
||||
native_tools: false,
|
||||
vision: false,
|
||||
};
|
||||
let mut history = vec![ChatMessage::user("hi")];
|
||||
let result = run_tool_call_loop(
|
||||
&provider,
|
||||
&mut history,
|
||||
&[],
|
||||
"test-provider",
|
||||
"model",
|
||||
0.0,
|
||||
true,
|
||||
None,
|
||||
"channel",
|
||||
&crate::openhuman::config::MultimodalConfig::default(),
|
||||
1,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("loop should succeed without stop hooks");
|
||||
assert_eq!(result, "done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_applies_per_tool_max_result_size_cap() {
|
||||
/// Tool that emits a 200k-char body and declares a 100-char cap
|
||||
/// via `max_result_size_chars`. The loop should truncate before
|
||||
/// threading the body into history.
|
||||
struct CappedHugeTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CappedHugeTool {
|
||||
fn name(&self) -> &str {
|
||||
"capped_huge"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"emits a giant body but caps itself"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object"})
|
||||
}
|
||||
async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult> {
|
||||
Ok(ToolResult::success("Z".repeat(200_000)))
|
||||
}
|
||||
fn permission_level(&self) -> crate::openhuman::tools::PermissionLevel {
|
||||
crate::openhuman::tools::PermissionLevel::ReadOnly
|
||||
}
|
||||
fn max_result_size_chars(&self) -> Option<usize> {
|
||||
Some(100)
|
||||
}
|
||||
}
|
||||
|
||||
let provider = ScriptedProvider {
|
||||
responses: Mutex::new(vec![
|
||||
// Round 1: ask for the tool.
|
||||
Ok(ChatResponse {
|
||||
text: Some(
|
||||
"<tool_call>{\"name\":\"capped_huge\",\"arguments\":{}}</tool_call>".into(),
|
||||
),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
}),
|
||||
// Round 2: stop.
|
||||
Ok(ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
}),
|
||||
]),
|
||||
native_tools: false,
|
||||
vision: false,
|
||||
};
|
||||
let mut history = vec![ChatMessage::user("call the tool")];
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(CappedHugeTool)];
|
||||
|
||||
let result = run_tool_call_loop(
|
||||
&provider,
|
||||
&mut history,
|
||||
&tools,
|
||||
"test-provider",
|
||||
"model",
|
||||
0.0,
|
||||
true,
|
||||
None,
|
||||
"channel",
|
||||
&crate::openhuman::config::MultimodalConfig::default(),
|
||||
2,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("loop with capped tool should succeed");
|
||||
assert_eq!(result, "done");
|
||||
|
||||
// Tool-results message should contain the truncation marker and
|
||||
// be far smaller than the 200k raw body (the 100-char cap plus a
|
||||
// small marker, well under 1k bytes total for this one call).
|
||||
let tool_results = history
|
||||
.iter()
|
||||
.find(|msg| msg.role == "user" && msg.content.contains("[Tool results]"))
|
||||
.expect("tool results should be appended to history");
|
||||
assert!(
|
||||
tool_results.content.contains("[truncated by tool cap:"),
|
||||
"expected truncation marker, got body: {}",
|
||||
&tool_results.content[..tool_results.content.len().min(200)]
|
||||
);
|
||||
assert!(
|
||||
tool_results.content.len() < 1_000,
|
||||
"raw 200k payload should not appear in history (got {} bytes)",
|
||||
tool_results.content.len()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
pub mod agents;
|
||||
pub mod bus;
|
||||
pub mod cost;
|
||||
pub mod debug;
|
||||
pub mod dispatcher;
|
||||
pub mod error;
|
||||
@@ -37,6 +38,7 @@ pub mod progress;
|
||||
/// a thin re-export shim for now.
|
||||
pub mod prompts;
|
||||
mod schemas;
|
||||
pub mod stop_hooks;
|
||||
pub mod tree_loader;
|
||||
pub mod triage;
|
||||
pub use schemas::{
|
||||
|
||||
@@ -56,9 +56,9 @@ pub enum AgentProgress {
|
||||
SubagentSpawned {
|
||||
agent_id: String,
|
||||
task_id: String,
|
||||
/// Resolved spawn mode — `"typed"` or `"fork"`. The UI uses this
|
||||
/// to distinguish narrow-prompt delegations from prefix-replay
|
||||
/// forks when labelling the live subagent block.
|
||||
/// Resolved spawn mode — currently always `"typed"`. Kept as a
|
||||
/// string so future modes (e.g. background/swarm) can land
|
||||
/// without changing the event shape.
|
||||
mode: String,
|
||||
/// `true` when the spawn was requested with
|
||||
/// `dedicated_thread: true`. The UI links the inline subagent
|
||||
@@ -165,6 +165,30 @@ pub enum AgentProgress {
|
||||
iteration: u32,
|
||||
},
|
||||
|
||||
/// Cumulative cost / token tally for the current turn, emitted
|
||||
/// after each provider response that carried a usage block.
|
||||
/// Consumers can render a live "$0.04 · 1.2k in / 480 out" line in
|
||||
/// the UI without subscribing to provider-level events.
|
||||
///
|
||||
/// `total_usd` prefers backend-reported `charged_amount_usd`
|
||||
/// (sum of authoritative figures) and falls back to a tier-based
|
||||
/// token-rate estimate for calls that didn't carry one — see
|
||||
/// [`crate::openhuman::agent::cost::TurnCost::total_usd`].
|
||||
TurnCostUpdated {
|
||||
/// Last model that contributed to this update.
|
||||
model: String,
|
||||
/// 1-based iteration index this update belongs to.
|
||||
iteration: u32,
|
||||
/// Cumulative input tokens across the turn.
|
||||
input_tokens: u64,
|
||||
/// Cumulative output tokens across the turn.
|
||||
output_tokens: u64,
|
||||
/// Cumulative cached prefix input tokens across the turn.
|
||||
cached_input_tokens: u64,
|
||||
/// Best-available USD total for the turn so far.
|
||||
total_usd: f64,
|
||||
},
|
||||
|
||||
/// The turn completed with a final text response.
|
||||
TurnCompleted {
|
||||
/// Total iterations used.
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! ## 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.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! ## Built-in hooks
|
||||
//!
|
||||
//! - [`BudgetStopHook`] — caps cumulative turn cost in USD using the
|
||||
//! [`super::cost::TurnCost`] accumulator.
|
||||
//! - [`MaxIterationsStopHook`] — caps iteration count from outside the
|
||||
//! `max_tool_iterations` config (useful for ad-hoc per-call limits
|
||||
//! without mutating the agent's persistent config).
|
||||
|
||||
use crate::openhuman::agent::cost::TurnCost;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A policy hook fired between iterations of the tool-call loop.
|
||||
#[async_trait]
|
||||
pub trait StopHook: Send + Sync {
|
||||
/// Stable name for tracing / error messages (e.g. `"budget"`).
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Inspect the current turn state and decide whether to continue.
|
||||
async fn check(&self, ctx: &TurnState<'_>) -> StopDecision;
|
||||
}
|
||||
|
||||
/// Outcome of a single hook check.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StopDecision {
|
||||
/// Keep the loop running.
|
||||
Continue,
|
||||
/// Stop the loop. `reason` is propagated to the caller.
|
||||
Stop { reason: String },
|
||||
}
|
||||
|
||||
/// Snapshot of the turn at the moment a hook fires. References are
|
||||
/// borrowed from the loop's locals so hooks pay no allocation cost on
|
||||
/// the hot path; clone fields out if you need to keep them.
|
||||
pub struct TurnState<'a> {
|
||||
/// 1-based iteration index that's about to start.
|
||||
pub iteration: u32,
|
||||
/// Configured iteration cap for this turn.
|
||||
pub max_iterations: u32,
|
||||
/// Cumulative cost / token tally so far.
|
||||
pub cost: &'a TurnCost,
|
||||
/// Model name passed to this turn's provider calls.
|
||||
pub model: &'a str,
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
/// Active stop hooks. `None` (the task-local-not-set state) is
|
||||
/// treated as "no hooks" — see [`current_stop_hooks`].
|
||||
pub static CURRENT_STOP_HOOKS: Vec<Arc<dyn StopHook>>;
|
||||
}
|
||||
|
||||
/// Returns a clone of the currently-installed hook list, or an empty
|
||||
/// vec when no scope has been entered.
|
||||
pub fn current_stop_hooks() -> Vec<Arc<dyn StopHook>> {
|
||||
CURRENT_STOP_HOOKS
|
||||
.try_with(|hooks| hooks.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Run `future` with `hooks` installed as the active stop-hook list.
|
||||
pub async fn with_stop_hooks<F, R>(hooks: Vec<Arc<dyn StopHook>>, future: F) -> R
|
||||
where
|
||||
F: std::future::Future<Output = R>,
|
||||
{
|
||||
CURRENT_STOP_HOOKS.scope(hooks, future).await
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Built-in hooks
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Stop the turn once cumulative cost reaches `max_usd`.
|
||||
///
|
||||
/// Uses [`TurnCost::total_usd`] which prefers the backend's
|
||||
/// `charged_amount_usd` and falls back to a tier-keyed estimate.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BudgetStopHook {
|
||||
pub max_usd: f64,
|
||||
}
|
||||
|
||||
impl BudgetStopHook {
|
||||
pub fn new(max_usd: f64) -> Self {
|
||||
Self { max_usd }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StopHook for BudgetStopHook {
|
||||
fn name(&self) -> &str {
|
||||
"budget"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &TurnState<'_>) -> StopDecision {
|
||||
// Fail closed on a malformed cap: NaN, non-finite, or
|
||||
// non-positive `max_usd` should *stop* rather than silently
|
||||
// disable the guard (NaN comparisons always return false, so
|
||||
// `spent >= NaN` would otherwise let the loop run forever).
|
||||
if !self.max_usd.is_finite() || self.max_usd <= 0.0 {
|
||||
return StopDecision::Stop {
|
||||
reason: format!("invalid budget cap configured: max_usd={}", self.max_usd),
|
||||
};
|
||||
}
|
||||
let spent = ctx.cost.total_usd();
|
||||
if spent >= self.max_usd {
|
||||
StopDecision::Stop {
|
||||
reason: format!(
|
||||
"turn cost ${spent:.4} reached cap ${cap:.4}",
|
||||
cap = self.max_usd
|
||||
),
|
||||
}
|
||||
} else {
|
||||
StopDecision::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the turn at a hard iteration ceiling.
|
||||
///
|
||||
/// Sibling of `max_tool_iterations` on `AgentConfig`; this hook is
|
||||
/// useful when callers want to lower the limit for one specific turn
|
||||
/// without mutating the agent's persistent config.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MaxIterationsStopHook {
|
||||
pub cap: u32,
|
||||
}
|
||||
|
||||
impl MaxIterationsStopHook {
|
||||
pub fn new(cap: u32) -> Self {
|
||||
Self { cap }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StopHook for MaxIterationsStopHook {
|
||||
fn name(&self) -> &str {
|
||||
"max_iterations"
|
||||
}
|
||||
|
||||
async fn check(&self, ctx: &TurnState<'_>) -> StopDecision {
|
||||
if ctx.iteration > self.cap {
|
||||
StopDecision::Stop {
|
||||
reason: format!(
|
||||
"turn reached iteration cap {} (about to start iteration {})",
|
||||
self.cap, ctx.iteration
|
||||
),
|
||||
}
|
||||
} else {
|
||||
StopDecision::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::providers::UsageInfo;
|
||||
|
||||
fn cost_with_usd(usd: f64) -> TurnCost {
|
||||
let mut tc = TurnCost::new();
|
||||
tc.add_call(
|
||||
"agentic-v1",
|
||||
&UsageInfo {
|
||||
charged_amount_usd: usd,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
tc
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_hook_continues_under_cap() {
|
||||
let cost = cost_with_usd(0.10);
|
||||
let hook = BudgetStopHook::new(1.00);
|
||||
let ctx = TurnState {
|
||||
iteration: 1,
|
||||
max_iterations: 10,
|
||||
cost: &cost,
|
||||
model: "agentic-v1",
|
||||
};
|
||||
assert!(matches!(hook.check(&ctx).await, StopDecision::Continue));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_hook_stops_at_cap() {
|
||||
let cost = cost_with_usd(1.50);
|
||||
let hook = BudgetStopHook::new(1.00);
|
||||
let ctx = TurnState {
|
||||
iteration: 2,
|
||||
max_iterations: 10,
|
||||
cost: &cost,
|
||||
model: "agentic-v1",
|
||||
};
|
||||
match hook.check(&ctx).await {
|
||||
StopDecision::Stop { reason } => {
|
||||
assert!(reason.contains("$1.5000"));
|
||||
assert!(reason.contains("$1.0000"));
|
||||
}
|
||||
other => panic!("expected Stop, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_hook_fails_closed_on_nan_cap() {
|
||||
// NaN comparisons always return false, so without the guard
|
||||
// `spent >= NaN` would silently disable the cap forever.
|
||||
let cost = cost_with_usd(1.0);
|
||||
let hook = BudgetStopHook::new(f64::NAN);
|
||||
let ctx = TurnState {
|
||||
iteration: 1,
|
||||
max_iterations: 10,
|
||||
cost: &cost,
|
||||
model: "agentic-v1",
|
||||
};
|
||||
match hook.check(&ctx).await {
|
||||
StopDecision::Stop { reason } => assert!(reason.contains("invalid budget cap")),
|
||||
other => panic!("expected Stop on NaN cap, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_hook_fails_closed_on_non_positive_cap() {
|
||||
let cost = TurnCost::new();
|
||||
let ctx = TurnState {
|
||||
iteration: 1,
|
||||
max_iterations: 10,
|
||||
cost: &cost,
|
||||
model: "agentic-v1",
|
||||
};
|
||||
for bad in [0.0, -1.0, f64::NEG_INFINITY, f64::INFINITY] {
|
||||
let hook = BudgetStopHook::new(bad);
|
||||
assert!(
|
||||
matches!(hook.check(&ctx).await, StopDecision::Stop { .. }),
|
||||
"cap {bad} should stop"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn max_iterations_hook_stops_when_exceeded() {
|
||||
let cost = TurnCost::new();
|
||||
let hook = MaxIterationsStopHook::new(3);
|
||||
let ctx = TurnState {
|
||||
iteration: 4,
|
||||
max_iterations: 10,
|
||||
cost: &cost,
|
||||
model: "agentic-v1",
|
||||
};
|
||||
assert!(matches!(hook.check(&ctx).await, StopDecision::Stop { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_stop_hooks_returns_empty_outside_scope() {
|
||||
assert!(current_stop_hooks().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_stop_hooks_installs_visible_within_scope() {
|
||||
let hooks: Vec<Arc<dyn StopHook>> = vec![Arc::new(BudgetStopHook::new(0.5))];
|
||||
with_stop_hooks(hooks, async {
|
||||
let visible = current_stop_hooks();
|
||||
assert_eq!(visible.len(), 1);
|
||||
assert_eq!(visible[0].name(), "budget");
|
||||
})
|
||||
.await;
|
||||
assert!(current_stop_hooks().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -992,6 +992,23 @@ fn spawn_progress_bridge(
|
||||
client_id={client_id} thread_id={thread_id} request_id={request_id}"
|
||||
);
|
||||
}
|
||||
AgentProgress::TurnCostUpdated {
|
||||
model,
|
||||
iteration,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cached_input_tokens,
|
||||
total_usd,
|
||||
} => {
|
||||
// Cost telemetry — not surfaced to the UI yet, but
|
||||
// logged at debug for now and ready for a future
|
||||
// socket payload.
|
||||
log::debug!(
|
||||
"[web_channel] turn cost update model={model} iter={iteration} \
|
||||
in={input_tokens} out={output_tokens} cached_in={cached_input_tokens} \
|
||||
total_usd={total_usd:.4} client_id={client_id} thread_id={thread_id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
turn_state.finish();
|
||||
|
||||
@@ -574,7 +574,6 @@ mod scoping_tests {
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
background: false,
|
||||
uses_fork_context: false,
|
||||
subagents: vec![],
|
||||
delegate_name: None,
|
||||
source: DefinitionSource::Builtin,
|
||||
|
||||
@@ -316,6 +316,14 @@ impl TurnStateMirror {
|
||||
}
|
||||
true
|
||||
}
|
||||
AgentProgress::TurnCostUpdated { .. } => {
|
||||
// Cost updates don't change the turn-state snapshot
|
||||
// shape (lifecycle / phase / active tool / etc.), so
|
||||
// we just acknowledge them without flushing. Surfacing
|
||||
// cost in the persisted snapshot would force a disk
|
||||
// flush per LLM call — not worth it for telemetry.
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,8 @@
|
||||
//! into a single text result that the parent receives as a normal
|
||||
//! `tool_result`.
|
||||
//!
|
||||
//! Modes:
|
||||
//! - `"typed"` (default) — narrow prompt + filtered tools + cheaper
|
||||
//! model. Use for delegated work where the parent doesn't need to
|
||||
//! share its full context.
|
||||
//! - `"fork"` — replay the parent's *exact* rendered prompt + tool
|
||||
//! schemas + message prefix. Use for parallel decomposition of a
|
||||
//! homogeneous task; relies on the inference backend's automatic
|
||||
//! prefix caching for token savings.
|
||||
//! Sub-agents always run in "typed" mode: a narrow archetype-specific
|
||||
//! prompt with a filtered tool list, on a cheaper model where applicable.
|
||||
//!
|
||||
use crate::core::event_bus::{publish_global, DomainEvent};
|
||||
use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
|
||||
@@ -100,7 +94,7 @@ impl Tool for SpawnSubagentTool {
|
||||
let agent_id_schema = if agent_ids.is_empty() {
|
||||
json!({
|
||||
"type": "string",
|
||||
"description": "Sub-agent id (e.g. code_executor, researcher, critic, fork)."
|
||||
"description": "Sub-agent id (e.g. code_executor, researcher, critic)."
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
@@ -132,11 +126,6 @@ impl Tool for SpawnSubagentTool {
|
||||
"type": "string",
|
||||
"description": "Composio toolkit slug to scope this spawn to — e.g. `gmail`, `notion`, `slack`. REQUIRED when `agent_id = \"integrations_agent\"`. Narrows the sub-agent's visible Composio actions AND its Connected Integrations prompt section to only that toolkit's catalogue, so the sub-agent's context window only carries the platform it was asked to operate on. Must match a currently-connected integration (see the Delegation Guide)."
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["typed", "fork"],
|
||||
"description": "`typed` (default) builds a narrow prompt + filtered tools. `fork` replays the parent's exact prompt for prefix-cache reuse on the inference backend."
|
||||
},
|
||||
"dedicated_thread": {
|
||||
"type": "boolean",
|
||||
"description": "Default `false`. Set `true` ONLY for long, complex sub-tasks where the parent thread should not be flooded with sub-agent output. The sub-agent's prompt and final summary land in a fresh worker-labeled thread the user can open from the thread list, and the parent receives a compact reference (worker thread id + brief summary) instead of the full transcript. Worker threads cannot themselves spawn another worker (sub-agents never see this tool), so this is a one-level-deep escape hatch."
|
||||
@@ -177,8 +166,6 @@ impl Tool for SpawnSubagentTool {
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("typed");
|
||||
|
||||
let dedicated_thread = args
|
||||
.get("dedicated_thread")
|
||||
.and_then(|v| v.as_bool())
|
||||
@@ -205,19 +192,12 @@ impl Tool for SpawnSubagentTool {
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve `mode` against the definition. Explicit `mode` argument
|
||||
// wins; otherwise we infer from the definition itself.
|
||||
let lookup_id = if mode == "fork" {
|
||||
"fork"
|
||||
} else {
|
||||
agent_id.as_str()
|
||||
};
|
||||
let definition = match registry.get(lookup_id) {
|
||||
let definition = match registry.get(agent_id.as_str()) {
|
||||
Some(def) => def,
|
||||
None => {
|
||||
let available: Vec<&str> = registry.list().iter().map(|d| d.id.as_str()).collect();
|
||||
return Ok(ToolResult::error(format!(
|
||||
"spawn_subagent: unknown agent_id '{lookup_id}'. Available: {}",
|
||||
"spawn_subagent: unknown agent_id '{agent_id}'. Available: {}",
|
||||
available.join(", ")
|
||||
)));
|
||||
}
|
||||
@@ -376,7 +356,7 @@ impl Tool for SpawnSubagentTool {
|
||||
publish_global(DomainEvent::SubagentSpawned {
|
||||
parent_session: parent_session.clone(),
|
||||
agent_id: definition.id.clone(),
|
||||
mode: mode.to_string(),
|
||||
mode: "typed".to_string(),
|
||||
task_id: task_id.clone(),
|
||||
prompt_chars: prompt.chars().count(),
|
||||
});
|
||||
@@ -391,7 +371,7 @@ impl Tool for SpawnSubagentTool {
|
||||
.send(AgentProgress::SubagentSpawned {
|
||||
agent_id: definition.id.clone(),
|
||||
task_id: task_id.clone(),
|
||||
mode: mode.to_string(),
|
||||
mode: "typed".to_string(),
|
||||
dedicated_thread,
|
||||
prompt_chars: prompt.chars().count(),
|
||||
})
|
||||
|
||||
@@ -40,6 +40,11 @@ impl Tool for FileReadTool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pure read — safe to fan out across parallel `file_read` calls.
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let path = args
|
||||
.get("path")
|
||||
|
||||
@@ -58,6 +58,11 @@ impl Tool for GlobTool {
|
||||
PermissionLevel::ReadOnly
|
||||
}
|
||||
|
||||
/// Pure read — safe to fan out across parallel `glob` calls.
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let pattern_str = args
|
||||
.get("pattern")
|
||||
|
||||
@@ -71,6 +71,11 @@ impl Tool for GrepTool {
|
||||
PermissionLevel::ReadOnly
|
||||
}
|
||||
|
||||
/// Pure read — safe to fan out across parallel `grep` calls.
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let pattern = args
|
||||
.get("pattern")
|
||||
|
||||
@@ -71,6 +71,22 @@ impl Tool for WebFetchTool {
|
||||
PermissionLevel::ReadOnly
|
||||
}
|
||||
|
||||
/// Idempotent GET — safe to fan out across parallel `web_fetch`
|
||||
/// calls. Targets that throttle aggressively are the user's
|
||||
/// concern; we don't try to second-guess at the tool layer.
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Cap web_fetch results at ~50k chars before they reach the
|
||||
/// model. The tool itself already truncates byte-wise via
|
||||
/// `max_bytes` (default 1MB), but a 1MB HTML page is still tens
|
||||
/// of thousands of tokens — the agent rarely needs that much, and
|
||||
/// when it does, `read_file` on a saved copy is the right tool.
|
||||
fn max_result_size_chars(&self) -> Option<usize> {
|
||||
Some(50_000)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let raw_url = args
|
||||
.get("url")
|
||||
|
||||
@@ -83,6 +83,15 @@ impl Tool for ShellTool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Cap shell output at ~30k chars before threading into history.
|
||||
/// Verbose commands (`find /`, dependency installs, log dumps)
|
||||
/// can otherwise blow past 100k chars in one call. The agent
|
||||
/// rarely needs the full firehose — a head/tail/grep follow-up is
|
||||
/// the right move when it does.
|
||||
fn max_result_size_chars(&self) -> Option<usize> {
|
||||
Some(30_000)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let command = args
|
||||
.get("command")
|
||||
|
||||
@@ -216,7 +216,6 @@ mod tests {
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
background: false,
|
||||
uses_fork_context: false,
|
||||
subagents: vec![],
|
||||
delegate_name: delegate_name.map(String::from),
|
||||
source: DefinitionSource::Builtin,
|
||||
|
||||
@@ -170,6 +170,48 @@ pub trait Tool: Send + Sync {
|
||||
ToolCategory::System
|
||||
}
|
||||
|
||||
/// Whether two concurrent invocations of this tool are safe to
|
||||
/// run in parallel inside a single LLM iteration.
|
||||
///
|
||||
/// Read-only tools that touch no shared mutable state should
|
||||
/// return `true` (the agent's tool loop can then `join_all` a
|
||||
/// batch of read calls instead of awaiting them serially). Tools
|
||||
/// that mutate the workspace, write to disk, or interact with
|
||||
/// external services that throttle by caller should leave the
|
||||
/// default `false`.
|
||||
///
|
||||
/// The argument is provided so a tool can refine the answer per
|
||||
/// call (e.g. a generic `bash` tool could allow parallel `ls` /
|
||||
/// `cat` invocations and reject parallel `npm install`s) — most
|
||||
/// tools will ignore it.
|
||||
///
|
||||
/// **Wiring note:** the parallel dispatcher in
|
||||
/// `harness::tool_loop` currently runs tool calls serially
|
||||
/// regardless of this flag. Annotating tools is still load-
|
||||
/// bearing: it lets the dispatch refactor land without
|
||||
/// coordinating with every tool author. See the parallel-tool
|
||||
/// dispatch follow-up issue.
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Per-tool cap on the character length of the result body sent
|
||||
/// back to the model.
|
||||
///
|
||||
/// When `Some(cap)` and the tool's `output_for_llm` exceeds it,
|
||||
/// the agent's tool loop truncates the body and appends a marker
|
||||
/// before threading the value into history — protecting the
|
||||
/// context window from one chatty tool. When `None` (the
|
||||
/// default), no per-tool cap applies and the global
|
||||
/// `PayloadSummarizer` (if any) handles oversize bodies.
|
||||
///
|
||||
/// Set this on tools whose output is *bounded but unpredictable*
|
||||
/// (`bash`, `web_fetch`, etc.); leave it unset on tools where
|
||||
/// callers genuinely want full content (`read_file`, `grep`).
|
||||
fn max_result_size_chars(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the full spec for LLM registration
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec {
|
||||
@@ -269,6 +311,18 @@ mod tests {
|
||||
assert_eq!(tool.category(), ToolCategory::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_concurrency_safe_is_false() {
|
||||
let tool = DummyTool;
|
||||
assert!(!tool.is_concurrency_safe(&serde_json::Value::Null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_max_result_size_chars_is_none() {
|
||||
let tool = DummyTool;
|
||||
assert!(tool.max_result_size_chars().is_none());
|
||||
}
|
||||
|
||||
// ── PermissionLevel ordering ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use openhuman_core::openhuman::agent::harness::{
|
||||
check_interrupt, current_fork, current_parent, with_fork_context, with_parent_context,
|
||||
ForkContext, InterruptFence, ParentExecutionContext,
|
||||
check_interrupt, current_parent, with_parent_context, InterruptFence, ParentExecutionContext,
|
||||
};
|
||||
use openhuman_core::openhuman::agent::hooks::{
|
||||
fire_hooks, sanitize_tool_output, PostTurnHook, ToolCallRecord, TurnContext,
|
||||
@@ -195,24 +194,8 @@ async fn interrupt_signal_handler_is_installable() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fork_and_parent_contexts_are_visible_only_within_scope() {
|
||||
async fn parent_context_is_visible_only_within_scope() {
|
||||
assert!(current_parent().is_none());
|
||||
assert!(current_fork().is_none());
|
||||
|
||||
let fork = ForkContext {
|
||||
system_prompt: Arc::new("hello".into()),
|
||||
tool_specs: Arc::new(vec![]),
|
||||
message_prefix: Arc::new(vec![ChatMessage::system("hello")]),
|
||||
fork_task_prompt: "do thing".into(),
|
||||
};
|
||||
|
||||
with_fork_context(fork, async {
|
||||
let inner = current_fork().expect("fork context should be visible");
|
||||
assert_eq!(*inner.system_prompt, "hello");
|
||||
assert_eq!(inner.fork_task_prompt, "do thing");
|
||||
assert_eq!(inner.message_prefix.len(), 1);
|
||||
})
|
||||
.await;
|
||||
|
||||
let parent = stub_parent_context();
|
||||
with_parent_context(parent, async {
|
||||
@@ -225,7 +208,6 @@ async fn fork_and_parent_contexts_are_visible_only_within_scope() {
|
||||
.await;
|
||||
|
||||
assert!(current_parent().is_none());
|
||||
assert!(current_fork().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user