diff --git a/app/src/features/human/SubMascotLayer.tsx b/app/src/features/human/SubMascotLayer.tsx index c012bc241..7b7e69c95 100644 --- a/app/src/features/human/SubMascotLayer.tsx +++ b/app/src/features/human/SubMascotLayer.tsx @@ -91,7 +91,7 @@ function activityForEntry(entry: ToolTimelineEntry): string { if (subagent.childIteration) { return subagent.childMaxIterations ? `Iteration ${subagent.childIteration}/${subagent.childMaxIterations}` - : `Iteration ${subagent.childIteration}`; + : `Step ${subagent.childIteration}`; } if (entry.detail?.trim()) { diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index c89c01610..9f59dfcaa 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2433,6 +2433,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.deleteCard': 'Delete', 'conversations.taskKanban.updateFailed': 'ولم يتمكن من استكمال المهمة؛ ولم يتم توفير التغييرات.', 'conversations.toolTimeline.turn': 'دور', + 'conversations.toolTimeline.step': 'خطوة', 'conversations.toolTimeline.workerThread': 'محادثة عامل', 'conversations.subagent.viewProcessing': 'عرض المعالجة الكاملة', 'conversations.subagent.parent': 'الأصل', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 694a2b66c..21c1cd4fc 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -2480,6 +2480,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.deleteCard': 'Delete', 'conversations.taskKanban.updateFailed': 'কাজটি সংরক্ষণ করা যায়নি।', 'conversations.toolTimeline.turn': 'টার্ন', + 'conversations.toolTimeline.step': 'ধাপ', 'conversations.toolTimeline.workerThread': 'ওয়ার্কার থ্রেড', 'conversations.subagent.viewProcessing': 'সম্পূর্ণ প্রক্রিয়া দেখুন', 'conversations.subagent.parent': 'মূল', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 5b2907a8e..95fa2102a 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2544,6 +2544,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.updateFailed': 'Aufgabe konnte nicht aktualisiert werden; Änderungen wurden nicht gespeichert.', 'conversations.toolTimeline.turn': 'drehen', + 'conversations.toolTimeline.step': 'Schritt', 'conversations.toolTimeline.workerThread': 'Worker-Thread', 'conversations.subagent.viewProcessing': 'Vollständige Verarbeitung anzeigen', 'conversations.subagent.parent': 'Übergeordnet', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a5e3ad69f..3443c761b 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2739,6 +2739,7 @@ const en: TranslationMap = { 'conversations.taskKanban.deleteCard': 'Delete', 'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.', 'conversations.toolTimeline.turn': 'turn', + 'conversations.toolTimeline.step': 'step', 'conversations.toolTimeline.workerThread': 'worker thread', 'conversations.subagent.viewProcessing': 'View full processing', 'conversations.subagent.parent': 'Parent', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 67113cb24..44e40d448 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2527,6 +2527,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.updateFailed': 'No se pudo actualizar la tarea; los cambios no se guardaron.', 'conversations.toolTimeline.turn': 'turno', + 'conversations.toolTimeline.step': 'Paso', 'conversations.toolTimeline.workerThread': 'hilo de worker', 'conversations.subagent.viewProcessing': 'Ver procesamiento completo', 'conversations.subagent.parent': 'Principal', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 4012a3f3f..8c209e1aa 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -2536,6 +2536,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.updateFailed': "Impossible de mettre à jour la tâche; les modifications n'ont pas été enregistrées.", 'conversations.toolTimeline.turn': 'tour', + 'conversations.toolTimeline.step': 'Étape', 'conversations.toolTimeline.workerThread': 'fil worker', 'conversations.subagent.viewProcessing': 'Voir le traitement complet', 'conversations.subagent.parent': 'Parent', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 36d8e1c85..9174de2fc 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -2484,6 +2484,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.deleteCard': 'Delete', 'conversations.taskKanban.updateFailed': 'कार्य अद्यतन नहीं कर सका; परिवर्तन बचाया नहीं गया।', 'conversations.toolTimeline.turn': 'टर्न', + 'conversations.toolTimeline.step': 'चरण', 'conversations.toolTimeline.workerThread': 'वर्कर थ्रेड', 'conversations.subagent.viewProcessing': 'पूरी प्रोसेसिंग देखें', 'conversations.subagent.parent': 'मूल', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 46819010a..4b885c4df 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -2486,6 +2486,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.deleteCard': 'Hapus', 'conversations.taskKanban.updateFailed': 'Tak bisa memutakhirkan tugas; perubahan tak disimpan.', 'conversations.toolTimeline.turn': 'giliran', + 'conversations.toolTimeline.step': 'Langkah', 'conversations.toolTimeline.workerThread': 'thread worker', 'conversations.subagent.viewProcessing': 'Lihat proses lengkap', 'conversations.subagent.parent': 'Induk', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 0b2b68ab2..2ccf962a5 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -2518,6 +2518,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.updateFailed': "Impossibile aggiornare l'attività; le modifiche non sono state salvate.", 'conversations.toolTimeline.turn': 'turno', + 'conversations.toolTimeline.step': 'Passo', 'conversations.toolTimeline.workerThread': 'thread worker', 'conversations.subagent.viewProcessing': 'Visualizza elaborazione completa', 'conversations.subagent.parent': 'Principale', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index da303403b..33e179022 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -2461,6 +2461,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.updateFailed': '작업을 업데이트할 수 없어 변경 사항이 저장되지 않았습니다.', 'conversations.toolTimeline.turn': '턴', + 'conversations.toolTimeline.step': '단계', 'conversations.toolTimeline.workerThread': '워커 스레드', 'conversations.subagent.viewProcessing': '전체 처리 과정 보기', 'conversations.subagent.parent': '상위', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 621d08fdc..1eaa55f23 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -2513,6 +2513,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.updateFailed': 'Nie udało się zaktualizować zadania; zmian nie zapisano.', 'conversations.toolTimeline.turn': 'tura', + 'conversations.toolTimeline.step': 'Krok', 'conversations.toolTimeline.workerThread': 'wątek workera', 'conversations.subagent.viewProcessing': 'Zobacz pełne przetwarzanie', 'conversations.subagent.parent': 'Nadrzędny', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 1c32f79ab..1612b29a1 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2525,6 +2525,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.updateFailed': 'Não foi possível atualizar a tarefa; as alterações não foram salvas.', 'conversations.toolTimeline.turn': 'turno', + 'conversations.toolTimeline.step': 'Passo', 'conversations.toolTimeline.workerThread': 'thread de worker', 'conversations.subagent.viewProcessing': 'Ver processamento completo', 'conversations.subagent.parent': 'Principal', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index c00692899..5ded85544 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -2498,6 +2498,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.deleteCard': 'Удалить', 'conversations.taskKanban.updateFailed': 'Не удалось обновить задачу; изменения не сохранились.', 'conversations.toolTimeline.turn': 'ход', + 'conversations.toolTimeline.step': 'Шаг', 'conversations.toolTimeline.workerThread': 'чат воркера', 'conversations.subagent.viewProcessing': 'Посмотреть весь процесс', 'conversations.subagent.parent': 'Родительский', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 510f28c9e..34b5aa53c 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2361,6 +2361,7 @@ const messages: TranslationMap = { 'conversations.taskKanban.deleteCard': '删除', 'conversations.taskKanban.updateFailed': '无法更新任务;更改未保存。', 'conversations.toolTimeline.turn': '轮次', + 'conversations.toolTimeline.step': '步骤', 'conversations.toolTimeline.workerThread': '工作线程', 'conversations.subagent.viewProcessing': '查看完整处理过程', 'conversations.subagent.parent': '父级', diff --git a/app/src/pages/conversations/components/SubagentDrawer.tsx b/app/src/pages/conversations/components/SubagentDrawer.tsx index 59ec3eafc..a2ce22d5e 100644 --- a/app/src/pages/conversations/components/SubagentDrawer.tsx +++ b/app/src/pages/conversations/components/SubagentDrawer.tsx @@ -197,10 +197,11 @@ export function SubagentDrawer({ {t(`conversations.subagent.${tone.label}`)} - {subagent.childIteration != null && subagent.childMaxIterations != null ? ( + {subagent.childIteration != null ? ( - {t('conversations.toolTimeline.turn')} {subagent.childIteration}/ - {subagent.childMaxIterations} + {subagent.childMaxIterations != null + ? `${t('conversations.toolTimeline.turn')} ${subagent.childIteration}/${subagent.childMaxIterations}` + : `${t('conversations.toolTimeline.step')} ${subagent.childIteration}`} ) : subagent.iterations != null ? ( diff --git a/app/src/pages/conversations/components/ToolTimelineBlock.tsx b/app/src/pages/conversations/components/ToolTimelineBlock.tsx index 6703fa734..e4d8b37bb 100644 --- a/app/src/pages/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/pages/conversations/components/ToolTimelineBlock.tsx @@ -51,10 +51,14 @@ export function SubagentActivityBlock({ const headerBits: string[] = []; if (subagent.mode) headerBits.push(subagent.mode); if (subagent.dedicatedThread) headerBits.push(t('conversations.toolTimeline.workerThread')); - if (subagent.childIteration != null && subagent.childMaxIterations != null) { - headerBits.push( - `${t('conversations.toolTimeline.turn')} ${subagent.childIteration}/${subagent.childMaxIterations}` - ); + if (subagent.childIteration != null) { + if (subagent.childMaxIterations != null) { + headerBits.push( + `${t('conversations.toolTimeline.turn')} ${subagent.childIteration}/${subagent.childMaxIterations}` + ); + } else { + headerBits.push(`${t('conversations.toolTimeline.step')} ${subagent.childIteration}`); + } } else if (subagent.iterations != null) { headerBits.push( subagent.iterations === 1 diff --git a/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx index 766a84e55..945a0e0c8 100644 --- a/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx +++ b/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx @@ -36,6 +36,17 @@ describe('SubagentActivityBlock', () => { expect(block.textContent).toContain('turn 2/5'); }); + it('renders "step N" when childMaxIterations is null (extended policy)', () => { + renderInStore( + + ); + const block = screen.getByTestId('subagent-activity'); + expect(block.textContent).toContain('step 7'); + expect(block.textContent).not.toContain('/'); + }); + it('renders final-run statistics on a completed sub-agent', () => { renderInStore( 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, diff --git a/src/openhuman/agent/harness/definition.rs b/src/openhuman/agent/harness/definition.rs index 4b78bcb88..25836ce3c 100644 --- a/src/openhuman/agent/harness/definition.rs +++ b/src/openhuman/agent/harness/definition.rs @@ -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), + } + } } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/harness/definition_tests.rs b/src/openhuman/agent/harness/definition_tests.rs index dba11bd33..ee575dba2 100644 --- a/src/openhuman/agent/harness/definition_tests.rs +++ b/src/openhuman/agent/harness/definition_tests.rs @@ -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); +} diff --git a/src/openhuman/agent/harness/engine/progress.rs b/src/openhuman/agent/harness/engine/progress.rs index 64648a6e2..56ffab7c4 100644 --- a/src/openhuman/agent/harness/engine/progress.rs +++ b/src/openhuman/agent/harness/engine/progress.rs @@ -184,6 +184,7 @@ pub(crate) struct SubagentProgress { pub sink: Option>, 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; } diff --git a/src/openhuman/agent/harness/payload_summarizer.rs b/src/openhuman/agent/harness/payload_summarizer.rs index 7c1c26c5b..b553e46b5 100644 --- a/src/openhuman/agent/harness/payload_summarizer.rs +++ b/src/openhuman/agent/harness/payload_summarizer.rs @@ -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, diff --git a/src/openhuman/agent/harness/subagent_runner/ops.rs b/src/openhuman/agent/harness/subagent_runner/ops.rs index 18cd8a239..5bd076ad3 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops.rs @@ -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, 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; diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index 0a1e3dd17..7e95870e0 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -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, diff --git a/src/openhuman/agent/harness/tool_loop.rs b/src/openhuman/agent/harness/tool_loop.rs index 4b9cbfbfb..0d3cc7314 100644 --- a/src/openhuman/agent/harness/tool_loop.rs +++ b/src/openhuman/agent/harness/tool_loop.rs @@ -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 diff --git a/src/openhuman/agent/progress.rs b/src/openhuman/agent/progress.rs index 106e35d05..1872f7d40 100644 --- a/src/openhuman/agent/progress.rs +++ b/src/openhuman/agent/progress.rs @@ -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 diff --git a/src/openhuman/agent_registry/agents/code_executor/agent.toml b/src/openhuman/agent_registry/agents/code_executor/agent.toml index 47e7d4c48..2816bc2a8 100644 --- a/src/openhuman/agent_registry/agents/code_executor/agent.toml +++ b/src/openhuman/agent_registry/agents/code_executor/agent.toml @@ -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 diff --git a/src/openhuman/agent_registry/agents/integrations_agent/agent.toml b/src/openhuman/agent_registry/agents/integrations_agent/agent.toml index 8d5ba929d..5bbba15d4 100644 --- a/src/openhuman/agent_registry/agents/integrations_agent/agent.toml +++ b/src/openhuman/agent_registry/agents/integrations_agent/agent.toml @@ -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 diff --git a/src/openhuman/agent_registry/agents/mcp_setup/agent.toml b/src/openhuman/agent_registry/agents/mcp_setup/agent.toml index 2c568fd36..f67c9d4c9 100644 --- a/src/openhuman/agent_registry/agents/mcp_setup/agent.toml +++ b/src/openhuman/agent_registry/agents/mcp_setup/agent.toml @@ -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 diff --git a/src/openhuman/agent_registry/agents/planner/agent.toml b/src/openhuman/agent_registry/agents/planner/agent.toml index efd26ce35..41946f08f 100644 --- a/src/openhuman/agent_registry/agents/planner/agent.toml +++ b/src/openhuman/agent_registry/agents/planner/agent.toml @@ -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 diff --git a/src/openhuman/agent_registry/agents/researcher/agent.toml b/src/openhuman/agent_registry/agents/researcher/agent.toml index b496f2809..9023a6c07 100644 --- a/src/openhuman/agent_registry/agents/researcher/agent.toml +++ b/src/openhuman/agent_registry/agents/researcher/agent.toml @@ -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 diff --git a/src/openhuman/agent_registry/agents/skill_creator/agent.toml b/src/openhuman/agent_registry/agents/skill_creator/agent.toml index f797f1f07..b43b25095 100644 --- a/src/openhuman/agent_registry/agents/skill_creator/agent.toml +++ b/src/openhuman/agent_registry/agents/skill_creator/agent.toml @@ -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 diff --git a/src/openhuman/agent_registry/agents/tools_agent/agent.toml b/src/openhuman/agent_registry/agents/tools_agent/agent.toml index 7b2dc0390..4ecccd3ad 100644 --- a/src/openhuman/agent_registry/agents/tools_agent/agent.toml +++ b/src/openhuman/agent_registry/agents/tools_agent/agent.toml @@ -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 diff --git a/src/openhuman/channels/providers/web.rs b/src/openhuman/channels/providers/web.rs index 93ced17ae..a86046b80 100644 --- a/src/openhuman/channels/providers/web.rs +++ b/src/openhuman/channels/providers/web.rs @@ -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() diff --git a/src/openhuman/channels/runtime/dispatch.rs b/src/openhuman/channels/runtime/dispatch.rs index 76f4fa401..712fdcf9e 100644 --- a/src/openhuman/channels/runtime/dispatch.rs +++ b/src/openhuman/channels/runtime/dispatch.rs @@ -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, diff --git a/src/openhuman/tools/orchestrator_tools.rs b/src/openhuman/tools/orchestrator_tools.rs index 53bcecd75..08ee0d8d5 100644 --- a/src/openhuman/tools/orchestrator_tools.rs +++ b/src/openhuman/tools/orchestrator_tools.rs @@ -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, diff --git a/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs b/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs index b56b55df5..84926d3f9 100644 --- a/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs +++ b/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs @@ -248,6 +248,7 @@ fn definition(max_iterations: usize) -> AgentDefinition { skill_filter: None, extra_tools: Vec::new(), max_iterations, + iteration_policy: Default::default(), max_result_chars: None, timeout_secs: None, sandbox_mode: SandboxMode::None, diff --git a/tests/agent_harness_leftovers_raw_coverage_e2e.rs b/tests/agent_harness_leftovers_raw_coverage_e2e.rs index a7bab5543..30b39e833 100644 --- a/tests/agent_harness_leftovers_raw_coverage_e2e.rs +++ b/tests/agent_harness_leftovers_raw_coverage_e2e.rs @@ -356,6 +356,7 @@ fn definition(max_result_chars: Option) -> AgentDefinition { skill_filter: None, extra_tools: Vec::new(), max_iterations: 2, + iteration_policy: Default::default(), max_result_chars, timeout_secs: None, sandbox_mode: SandboxMode::None, diff --git a/tests/agent_harness_raw_coverage_e2e.rs b/tests/agent_harness_raw_coverage_e2e.rs index 3d1fe5b1c..ad2c35872 100644 --- a/tests/agent_harness_raw_coverage_e2e.rs +++ b/tests/agent_harness_raw_coverage_e2e.rs @@ -273,6 +273,7 @@ fn coverage_definition() -> AgentDefinition { skill_filter: None, extra_tools: Vec::new(), max_iterations: 3, + iteration_policy: Default::default(), max_result_chars: Some(18), timeout_secs: None, sandbox_mode: SandboxMode::ReadOnly, diff --git a/tests/agent_large_round25_raw_coverage_e2e.rs b/tests/agent_large_round25_raw_coverage_e2e.rs index cc22ff479..77b7933f5 100644 --- a/tests/agent_large_round25_raw_coverage_e2e.rs +++ b/tests/agent_large_round25_raw_coverage_e2e.rs @@ -289,6 +289,7 @@ fn integrations_definition() -> AgentDefinition { skill_filter: None, extra_tools: Vec::new(), max_iterations: 4, + iteration_policy: Default::default(), max_result_chars: None, timeout_secs: None, sandbox_mode: SandboxMode::None, diff --git a/tests/agent_prompts_subagent_raw_coverage_e2e.rs b/tests/agent_prompts_subagent_raw_coverage_e2e.rs index d681e6b42..ded8520e3 100644 --- a/tests/agent_prompts_subagent_raw_coverage_e2e.rs +++ b/tests/agent_prompts_subagent_raw_coverage_e2e.rs @@ -254,6 +254,7 @@ fn definition(prompt: PromptSource) -> AgentDefinition { skill_filter: None, extra_tools: Vec::new(), max_iterations: 3, + iteration_policy: Default::default(), max_result_chars: None, timeout_secs: None, sandbox_mode: SandboxMode::None, diff --git a/tests/agent_session_turn_raw_coverage_e2e.rs b/tests/agent_session_turn_raw_coverage_e2e.rs index 0a6066647..48be5ca89 100644 --- a/tests/agent_session_turn_raw_coverage_e2e.rs +++ b/tests/agent_session_turn_raw_coverage_e2e.rs @@ -1002,6 +1002,7 @@ fn definition( skill_filter: None, extra_tools: Vec::new(), max_iterations, + iteration_policy: Default::default(), max_result_chars, timeout_secs: None, sandbox_mode: SandboxMode::None, diff --git a/tests/inference_agent_raw_coverage_e2e.rs b/tests/inference_agent_raw_coverage_e2e.rs index 1c6c99118..0c9c7f44d 100644 --- a/tests/inference_agent_raw_coverage_e2e.rs +++ b/tests/inference_agent_raw_coverage_e2e.rs @@ -1570,6 +1570,7 @@ named = ["todo", "plan_exit"] skill_filter: None, extra_tools: Vec::new(), max_iterations: 8, + iteration_policy: Default::default(), max_result_chars: None, timeout_secs: None, sandbox_mode: SandboxMode::None, diff --git a/tests/memory_threads_raw_coverage_e2e.rs b/tests/memory_threads_raw_coverage_e2e.rs index 11c192216..3db3b7dc6 100644 --- a/tests/memory_threads_raw_coverage_e2e.rs +++ b/tests/memory_threads_raw_coverage_e2e.rs @@ -3189,6 +3189,7 @@ fn turn_state_mirror_persists_progress_edges_from_public_events() { task_id: "task-1".into(), iteration: 1, max_iterations: 3, + extended_policy: false, })); assert!(!mirror.observe(&AgentProgress::SubagentToolCallStarted { agent_id: "researcher".into(), diff --git a/tests/tools_agent_credentials_state_raw_coverage_e2e.rs b/tests/tools_agent_credentials_state_raw_coverage_e2e.rs index 4be7edcd1..57c669cab 100644 --- a/tests/tools_agent_credentials_state_raw_coverage_e2e.rs +++ b/tests/tools_agent_credentials_state_raw_coverage_e2e.rs @@ -392,6 +392,7 @@ fn agent_definition(id: &str, max_result_chars: Option) -> AgentDefinitio skill_filter: None, extra_tools: Vec::new(), max_iterations: 2, + iteration_policy: Default::default(), max_result_chars, timeout_secs: None, sandbox_mode: SandboxMode::ReadOnly, diff --git a/tests/tools_approval_channels_raw_coverage_e2e.rs b/tests/tools_approval_channels_raw_coverage_e2e.rs index 64b596e5a..7910421e4 100644 --- a/tests/tools_approval_channels_raw_coverage_e2e.rs +++ b/tests/tools_approval_channels_raw_coverage_e2e.rs @@ -288,6 +288,7 @@ fn coverage_agent_definition( skill_filter: None, extra_tools: vec![], max_iterations: 8, + iteration_policy: Default::default(), max_result_chars: None, timeout_secs: None, sandbox_mode: SandboxMode::None,