mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat(agent): apply iteration turn cap only to short-running agents (#3056)
This commit is contained in:
@@ -67,6 +67,7 @@ pub(crate) fn test_inherit_echo_def() -> AgentDefinition {
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
max_iterations: 3,
|
||||
iteration_policy: Default::default(),
|
||||
max_result_chars: None,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
@@ -102,6 +103,7 @@ pub(crate) fn test_inherit_parallel_worker_def() -> AgentDefinition {
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
max_iterations: 6,
|
||||
iteration_policy: Default::default(),
|
||||
max_result_chars: None,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
|
||||
@@ -25,6 +25,30 @@ use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Iteration-cap policy for a sub-agent.
|
||||
///
|
||||
/// Controls how the harness enforces [`AgentDefinition::max_iterations`]:
|
||||
///
|
||||
/// * **Strict** — hard-fail at `max_iterations` (the current default).
|
||||
/// Right for short-running agents (summarizer, triage) where hitting
|
||||
/// 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))
|
||||
/// 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
|
||||
/// a misleading terminal countdown.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IterationPolicy {
|
||||
/// Hard cap at `max_iterations`. Default for most agents.
|
||||
#[default]
|
||||
Strict,
|
||||
/// Raised cap for multi-step specialists. Guards still apply.
|
||||
Extended,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Agent definition
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -127,6 +151,12 @@ pub struct AgentDefinition {
|
||||
#[serde(default = "defaults::max_iterations")]
|
||||
pub max_iterations: usize,
|
||||
|
||||
/// Iteration-cap policy. See [`IterationPolicy`] for semantics.
|
||||
/// Defaults to [`IterationPolicy::Strict`]; long-running specialists
|
||||
/// set `iteration_policy = "extended"` in their `agent.toml`.
|
||||
#[serde(default)]
|
||||
pub iteration_policy: IterationPolicy,
|
||||
|
||||
/// Maximum character length for this sub-agent's output before the
|
||||
/// harness truncates it before feeding it back as a tool result to the
|
||||
/// parent. `None` means no cap (the default for most agents). Set to
|
||||
@@ -317,6 +347,20 @@ impl AgentDefinition {
|
||||
pub fn display_name(&self) -> &str {
|
||||
self.display_name.as_deref().unwrap_or(&self.id)
|
||||
}
|
||||
|
||||
/// Effective iteration cap after applying [`IterationPolicy`].
|
||||
///
|
||||
/// * `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).
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,7 @@ fn make_def(id: &str) -> AgentDefinition {
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
max_iterations: 8,
|
||||
iteration_policy: Default::default(),
|
||||
max_result_chars: None,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
@@ -178,3 +179,67 @@ fn skills_wildcard_only_star_matches_all() {
|
||||
};
|
||||
assert!(!specific.matches_all());
|
||||
}
|
||||
|
||||
// ── iteration policy ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn strict_policy_returns_max_iterations_unchanged() {
|
||||
let mut def = make_def("summarizer");
|
||||
def.max_iterations = 2;
|
||||
def.iteration_policy = IterationPolicy::Strict;
|
||||
assert_eq!(def.effective_max_iterations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_policy_raises_cap_to_at_least_extended_constant() {
|
||||
let mut def = make_def("code_executor");
|
||||
def.max_iterations = 10;
|
||||
def.iteration_policy = IterationPolicy::Extended;
|
||||
assert_eq!(
|
||||
def.effective_max_iterations(),
|
||||
super::super::tool_loop::EXTENDED_MAX_TOOL_ITERATIONS
|
||||
);
|
||||
assert!(def.effective_max_iterations() > def.max_iterations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_policy_preserves_custom_cap_when_higher_than_constant() {
|
||||
let mut def = make_def("custom_agent");
|
||||
def.max_iterations = 100;
|
||||
def.iteration_policy = IterationPolicy::Extended;
|
||||
assert_eq!(def.effective_max_iterations(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iteration_policy_defaults_to_strict() {
|
||||
let def = make_def("test");
|
||||
assert_eq!(def.iteration_policy, IterationPolicy::Strict);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iteration_policy_parses_from_toml() {
|
||||
let toml_src = r#"
|
||||
id = "code_executor"
|
||||
when_to_use = "Runs code"
|
||||
max_iterations = 10
|
||||
iteration_policy = "extended"
|
||||
"#;
|
||||
let def: AgentDefinition = toml::from_str(toml_src).expect("toml parse");
|
||||
assert_eq!(def.iteration_policy, IterationPolicy::Extended);
|
||||
assert_eq!(
|
||||
def.effective_max_iterations(),
|
||||
super::super::tool_loop::EXTENDED_MAX_TOOL_ITERATIONS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iteration_policy_omitted_defaults_strict() {
|
||||
let toml_src = r#"
|
||||
id = "summarizer"
|
||||
when_to_use = "Summarizes"
|
||||
max_iterations = 1
|
||||
"#;
|
||||
let def: AgentDefinition = toml::from_str(toml_src).expect("toml parse");
|
||||
assert_eq!(def.iteration_policy, IterationPolicy::Strict);
|
||||
assert_eq!(def.effective_max_iterations(), 1);
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ 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]
|
||||
@@ -196,6 +197,7 @@ impl ProgressReporter for SubagentProgress {
|
||||
task_id: self.task_id.clone(),
|
||||
iteration,
|
||||
max_iterations,
|
||||
extended_policy: self.extended_policy,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -359,6 +359,7 @@ mod tests {
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
max_iterations: 1,
|
||||
iteration_policy: Default::default(),
|
||||
max_result_chars: None,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
|
||||
@@ -23,7 +23,9 @@ use super::tool_prep::{
|
||||
load_prompt_source, top_k_for_toolkit,
|
||||
};
|
||||
use super::types::{SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome};
|
||||
use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource};
|
||||
use crate::openhuman::agent::harness::definition::{
|
||||
AgentDefinition, IterationPolicy, PromptSource,
|
||||
};
|
||||
use crate::openhuman::agent::harness::{
|
||||
current_spawn_depth, with_current_sandbox_mode, with_spawn_depth, MAX_SPAWN_DEPTH,
|
||||
};
|
||||
@@ -976,7 +978,8 @@ async fn run_typed_mode(
|
||||
agent_id = %definition.id,
|
||||
model = %model,
|
||||
tool_count = allowed_names.len(),
|
||||
max_iterations = definition.max_iterations,
|
||||
max_iterations = definition.effective_max_iterations(),
|
||||
iteration_policy = ?definition.iteration_policy,
|
||||
"[subagent_runner:typed] resolved configuration"
|
||||
);
|
||||
|
||||
@@ -1172,12 +1175,13 @@ async fn run_typed_mode(
|
||||
lazy_resolver,
|
||||
&model,
|
||||
temperature,
|
||||
definition.max_iterations,
|
||||
definition.effective_max_iterations(),
|
||||
task_id,
|
||||
&definition.id,
|
||||
options.worker_thread_id.clone(),
|
||||
handoff_cache.as_deref(),
|
||||
parent,
|
||||
definition.iteration_policy == IterationPolicy::Extended,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1231,6 +1235,7 @@ async fn run_inner_loop(
|
||||
worker_thread_id: Option<String>,
|
||||
handoff_cache: Option<&ResultHandoffCache>,
|
||||
parent: &ParentExecutionContext,
|
||||
extended_policy: bool,
|
||||
) -> Result<(String, usize, AggregatedUsage), 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.
|
||||
@@ -1333,6 +1338,7 @@ async fn run_inner_loop(
|
||||
sink: parent.on_progress.clone(),
|
||||
agent_id: agent_id.to_string(),
|
||||
task_id: task_id.to_string(),
|
||||
extended_policy,
|
||||
};
|
||||
|
||||
let parser = super::super::engine::DefaultParser;
|
||||
|
||||
@@ -58,6 +58,7 @@ fn make_def_named_tools(names: &[&str]) -> AgentDefinition {
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
max_iterations: 5,
|
||||
iteration_policy: Default::default(),
|
||||
max_result_chars: None,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: crate::openhuman::agent::harness::definition::SandboxMode::None,
|
||||
|
||||
@@ -14,6 +14,13 @@ pub(crate) const STREAM_CHUNK_MIN_CHARS: usize = 80;
|
||||
/// 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
|
||||
|
||||
@@ -113,6 +113,9 @@ pub enum AgentProgress {
|
||||
iteration: u32,
|
||||
/// Maximum iterations configured for this child run.
|
||||
max_iterations: u32,
|
||||
/// `true` when the agent uses [`IterationPolicy::Extended`](crate::openhuman::agent::harness::definition::IterationPolicy::Extended).
|
||||
/// The UI uses this to show "step N" instead of "turn N/M".
|
||||
extended_policy: bool,
|
||||
},
|
||||
|
||||
/// A sub-agent is about to execute a tool. Distinct from
|
||||
|
||||
@@ -4,6 +4,7 @@ delegate_name = "run_code"
|
||||
when_to_use = "Code-repo worker — owns the FULL lifecycle of any task scoped to a code repository: clone, navigate via `codegraph_search` / `grep` / `lsp`, read files, edit (`edit` / `apply_patch` / `file_write`), build, run tests/lint, and drive **local `git`** (branch / commit / push / diff) for the working tree. **GitHub state I/O (issues, PRs, comments, reviews, checks, labels) goes through `composio_execute` with the matching `GITHUB_*` tool — never `gh` CLI** (see code_executor prompt.md 'GitHub I/O' section for the full split rule and rationale). Use for ANY repo-scoped work — locating where to edit, investigating a bug, exploring a codebase, and any modify / build / test / git / push / PR step — not only for the literal 'writing code' moment. Keep the entire end-to-end flow inside one `delegate_run_code` call so the worker accumulates context across steps."
|
||||
temperature = 0.4
|
||||
max_iterations = 10
|
||||
iteration_policy = "extended"
|
||||
max_result_chars = 16000
|
||||
sandbox_mode = "sandboxed"
|
||||
omit_identity = true
|
||||
|
||||
@@ -3,6 +3,7 @@ display_name = "Integrations Agent"
|
||||
when_to_use = "Service integration specialist — drives a SINGLE Composio toolkit per spawn (gmail, notion, github, slack, …). The `toolkit` argument is mandatory. Use when a task should be completed via a managed OAuth integration rather than raw HTTP / file I/O."
|
||||
temperature = 0.4
|
||||
max_iterations = 10
|
||||
iteration_policy = "extended"
|
||||
sandbox_mode = "none"
|
||||
omit_identity = true
|
||||
omit_memory_context = true
|
||||
|
||||
@@ -4,6 +4,7 @@ delegate_name = "setup_mcp_server"
|
||||
when_to_use = "Walks the user through installing and connecting an MCP server end-to-end. Use when the user asks to add / install / set up an MCP server (e.g. \"set up the Notion MCP\", \"connect the GitHub MCP server\"). Owns the full flow: search registries → ask the user for any required secrets via a native dialog (raw values never enter the agent context) → test the connection → commit the install."
|
||||
temperature = 0.3
|
||||
max_iterations = 12
|
||||
iteration_policy = "extended"
|
||||
sandbox_mode = "none"
|
||||
omit_identity = true
|
||||
omit_memory_context = true
|
||||
|
||||
@@ -4,6 +4,7 @@ delegate_name = "plan"
|
||||
when_to_use = "Architect — break a complex task into a small DAG of subtasks with explicit acceptance criteria. Reads memory and searches the web to ground plans in real context. Read-only; produces JSON, not code."
|
||||
temperature = 0.4
|
||||
max_iterations = 8
|
||||
iteration_policy = "extended"
|
||||
max_result_chars = 8000
|
||||
sandbox_mode = "read_only"
|
||||
omit_identity = true
|
||||
|
||||
@@ -4,6 +4,7 @@ delegate_name = "research"
|
||||
when_to_use = "Web & docs crawler — reads real documentation, compresses to dense markdown. Use for any task that requires looking up external knowledge."
|
||||
temperature = 0.4
|
||||
max_iterations = 8
|
||||
iteration_policy = "extended"
|
||||
max_result_chars = 8000
|
||||
sandbox_mode = "none"
|
||||
omit_identity = true
|
||||
|
||||
@@ -4,6 +4,7 @@ delegate_name = "create_skill"
|
||||
when_to_use = "JavaScript skill/runtime specialist — creates or updates OpenHuman SKILL.md packages, Node-backed JS helpers, and tool-facing JavaScript code meant to be executed by the core's Node runtime bridge or by other agents."
|
||||
temperature = 0.3
|
||||
max_iterations = 10
|
||||
iteration_policy = "extended"
|
||||
max_result_chars = 16000
|
||||
sandbox_mode = "sandboxed"
|
||||
omit_identity = true
|
||||
|
||||
@@ -3,6 +3,7 @@ display_name = "Tools Agent"
|
||||
when_to_use = "Generalist for heavyweight ad-hoc execution that does NOT touch a code repository — host shell, HTTP/web fetch, web search, memory helpers, file READS (`file_read` / `grep` / `glob`). Lacks `edit` / `apply_patch` / `file_write` / `git_operations` / `codegraph_search` — do **not** use for any task scoped to a code repo (cloning, locating, modifying, building, testing, git, push, PR); those route to `delegate_run_code` end-to-end. Do not use for managed Composio OAuth integrations either — those route to `integrations_agent` with a `toolkit` argument."
|
||||
temperature = 0.4
|
||||
max_iterations = 10
|
||||
iteration_policy = "extended"
|
||||
sandbox_mode = "none"
|
||||
omit_identity = true
|
||||
omit_memory_context = true
|
||||
|
||||
@@ -1228,21 +1228,28 @@ fn spawn_progress_bridge(
|
||||
task_id,
|
||||
iteration,
|
||||
max_iterations,
|
||||
extended_policy,
|
||||
} => {
|
||||
publish_web_channel_event(WebChannelEvent {
|
||||
event: "subagent_iteration_start".to_string(),
|
||||
client_id: client_id.clone(),
|
||||
thread_id: thread_id.clone(),
|
||||
request_id: request_id.clone(),
|
||||
message: Some(format!(
|
||||
"Sub-agent '{agent_id}' iteration {iteration}/{max_iterations}"
|
||||
)),
|
||||
message: Some(if extended_policy {
|
||||
format!("Sub-agent '{agent_id}' step {iteration}")
|
||||
} else {
|
||||
format!("Sub-agent '{agent_id}' iteration {iteration}/{max_iterations}")
|
||||
}),
|
||||
tool_name: Some(agent_id),
|
||||
skill_id: Some(task_id),
|
||||
round: Some(round),
|
||||
subagent: Some(SubagentProgressDetail {
|
||||
child_iteration: Some(iteration),
|
||||
child_max_iterations: Some(max_iterations),
|
||||
child_max_iterations: if extended_policy {
|
||||
None
|
||||
} else {
|
||||
Some(max_iterations)
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
|
||||
@@ -463,6 +463,7 @@ mod scoping_tests {
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
max_iterations: 8,
|
||||
iteration_policy: Default::default(),
|
||||
max_result_chars: None,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
|
||||
@@ -274,6 +274,7 @@ mod tests {
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
max_iterations: 8,
|
||||
iteration_policy: Default::default(),
|
||||
max_result_chars: None,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
|
||||
Reference in New Issue
Block a user