From daa0a13f2ee3eff51a366f4b14fd2e9238d7773f Mon Sep 17 00:00:00 2001
From: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Date: Sat, 30 May 2026 21:27:40 -0700
Subject: [PATCH] feat(agent): apply iteration turn cap only to short-running
agents (#3056)
---
app/src/features/human/SubMascotLayer.tsx | 2 +-
app/src/lib/i18n/ar.ts | 1 +
app/src/lib/i18n/bn.ts | 1 +
app/src/lib/i18n/de.ts | 1 +
app/src/lib/i18n/en.ts | 1 +
app/src/lib/i18n/es.ts | 1 +
app/src/lib/i18n/fr.ts | 1 +
app/src/lib/i18n/hi.ts | 1 +
app/src/lib/i18n/id.ts | 1 +
app/src/lib/i18n/it.ts | 1 +
app/src/lib/i18n/ko.ts | 1 +
app/src/lib/i18n/pl.ts | 1 +
app/src/lib/i18n/pt.ts | 1 +
app/src/lib/i18n/ru.ts | 1 +
app/src/lib/i18n/zh-CN.ts | 1 +
.../components/SubagentDrawer.tsx | 7 +-
.../components/ToolTimelineBlock.tsx | 12 ++--
.../__tests__/ToolTimelineBlock.test.tsx | 11 ++++
.../agent/harness/builtin_definitions.rs | 2 +
src/openhuman/agent/harness/definition.rs | 44 +++++++++++++
.../agent/harness/definition_tests.rs | 65 +++++++++++++++++++
.../agent/harness/engine/progress.rs | 2 +
.../agent/harness/payload_summarizer.rs | 1 +
.../agent/harness/subagent_runner/ops.rs | 12 +++-
.../harness/subagent_runner/ops_tests.rs | 1 +
src/openhuman/agent/harness/tool_loop.rs | 7 ++
src/openhuman/agent/progress.rs | 3 +
.../agents/code_executor/agent.toml | 1 +
.../agents/integrations_agent/agent.toml | 1 +
.../agents/mcp_setup/agent.toml | 1 +
.../agent_registry/agents/planner/agent.toml | 1 +
.../agents/researcher/agent.toml | 1 +
.../agents/skill_creator/agent.toml | 1 +
.../agents/tools_agent/agent.toml | 1 +
src/openhuman/channels/providers/web.rs | 15 +++--
src/openhuman/channels/runtime/dispatch.rs | 1 +
src/openhuman/tools/orchestrator_tools.rs | 1 +
...rchivist_debug_round21_raw_coverage_e2e.rs | 1 +
...gent_harness_leftovers_raw_coverage_e2e.rs | 1 +
tests/agent_harness_raw_coverage_e2e.rs | 1 +
tests/agent_large_round25_raw_coverage_e2e.rs | 1 +
...agent_prompts_subagent_raw_coverage_e2e.rs | 1 +
tests/agent_session_turn_raw_coverage_e2e.rs | 1 +
tests/inference_agent_raw_coverage_e2e.rs | 1 +
tests/memory_threads_raw_coverage_e2e.rs | 1 +
...gent_credentials_state_raw_coverage_e2e.rs | 1 +
...ools_approval_channels_raw_coverage_e2e.rs | 1 +
47 files changed, 202 insertions(+), 15 deletions(-)
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,