TinyAgents migration parity & bug fixes (#4451–#4469) (#4495)

This commit is contained in:
Steven Enamakel
2026-07-04 09:56:11 -07:00
committed by GitHub
parent 4321f7fc64
commit f89c5e0473
62 changed files with 4320 additions and 539 deletions
+6
View File
@@ -60,6 +60,12 @@ const messages: TranslationMap = {
'تحقّق من اتصالك أو إعدادات النموذج؛ سيعيد OpenHuman المحاولة.',
'conversations.toolFailure.timeout.cause': 'استغرق الإجراء وقتًا طويلاً وتم إيقافه.',
'conversations.toolFailure.timeout.next': 'سيعيد OpenHuman المحاولة، أو يمكنك إعادتها يدويًا.',
'conversations.toolFailure.denied.cause': 'لقد رفضت هذا الإجراء.',
'conversations.toolFailure.denied.next':
'لا حاجة لأي شيء — لم يُنفَّذ. اطلبه مجددًا إذا غيّرت رأيك.',
'conversations.toolFailure.approvalExpired.cause': 'انتهت صلاحية طلب الموافقة قبل أن يردّ أحد.',
'conversations.toolFailure.approvalExpired.next':
'اطلبه مجددًا لتشغيله — لن يعيد OpenHuman المحاولة من تلقاء نفسه.',
'conversations.toolFailure.unknown.cause': 'حدث خطأ ما في هذا الإجراء.',
'conversations.toolFailure.unknown.next':
'حاول مرة أخرى؛ وإذا استمر الفشل، شغّل التشخيص من الإعدادات.',
+6
View File
@@ -64,6 +64,12 @@ const messages: TranslationMap = {
'conversations.toolFailure.timeout.cause': 'কাজটি অনেক বেশি সময় নেওয়ায় থামিয়ে দেওয়া হয়েছে।',
'conversations.toolFailure.timeout.next':
'OpenHuman আবার চেষ্টা করবে, অথবা আপনি নিজে আবার চেষ্টা করতে পারেন।',
'conversations.toolFailure.denied.cause': 'আপনি এই কাজটি প্রত্যাখ্যান করেছেন।',
'conversations.toolFailure.denied.next': 'কিছু করার নেই — এটি চালানো হয়নি। মত বদলালে আবার বলুন।',
'conversations.toolFailure.approvalExpired.cause':
'কেউ সাড়া দেওয়ার আগেই অনুমোদনের অনুরোধের মেয়াদ শেষ হয়ে গেছে।',
'conversations.toolFailure.approvalExpired.next':
'এটি চালাতে আবার বলুন — OpenHuman নিজে থেকে পুনরায় চেষ্টা করবে না।',
'conversations.toolFailure.unknown.cause': 'এই কাজটিতে কিছু ভুল হয়েছে।',
'conversations.toolFailure.unknown.next':
'আবার চেষ্টা করুন; বারবার ব্যর্থ হলে সেটিংস থেকে ডায়াগনস্টিকস চালান।',
+7
View File
@@ -68,6 +68,13 @@ const messages: TranslationMap = {
'conversations.toolFailure.timeout.cause': 'Die Aktion hat zu lange gedauert und wurde gestoppt.',
'conversations.toolFailure.timeout.next':
'OpenHuman versucht es erneut, oder du kannst es manuell wiederholen.',
'conversations.toolFailure.denied.cause': 'Du hast diese Aktion abgelehnt.',
'conversations.toolFailure.denied.next':
'Nichts zu tun — sie wurde nicht ausgeführt. Frag erneut, wenn du es dir anders überlegst.',
'conversations.toolFailure.approvalExpired.cause':
'Die Genehmigungsanfrage ist abgelaufen, bevor jemand geantwortet hat.',
'conversations.toolFailure.approvalExpired.next':
'Frag erneut, um sie auszuführen — OpenHuman versucht es nicht von selbst erneut.',
'conversations.toolFailure.unknown.cause': 'Bei dieser Aktion ist etwas schiefgelaufen.',
'conversations.toolFailure.unknown.next':
'Versuche es erneut; wenn es weiterhin fehlschlägt, führe die Diagnose in den Einstellungen aus.',
+7
View File
@@ -3852,6 +3852,13 @@ const en: TranslationMap = {
'conversations.toolFailure.timeout.cause': 'The action took too long and was stopped.',
'conversations.toolFailure.timeout.next':
'OpenHuman will try again, or you can retry it manually.',
'conversations.toolFailure.denied.cause': 'You declined this action.',
'conversations.toolFailure.denied.next':
'Nothing to do — it was not run. Ask again if you change your mind.',
'conversations.toolFailure.approvalExpired.cause':
'The approval request expired before anyone responded.',
'conversations.toolFailure.approvalExpired.next':
"Ask again to run it — OpenHuman won't retry it on its own.",
'conversations.toolFailure.unknown.cause': 'Something went wrong with this action.',
'conversations.toolFailure.unknown.next':
'Try again; if it keeps failing, run diagnostics from Settings.',
+7
View File
@@ -66,6 +66,13 @@ const messages: TranslationMap = {
'conversations.toolFailure.timeout.cause': 'La acción tardó demasiado y se detuvo.',
'conversations.toolFailure.timeout.next':
'OpenHuman lo intentará de nuevo, o puedes reintentarlo manualmente.',
'conversations.toolFailure.denied.cause': 'Rechazaste esta acción.',
'conversations.toolFailure.denied.next':
'No hay nada que hacer: no se ejecutó. Vuelve a pedirlo si cambias de opinión.',
'conversations.toolFailure.approvalExpired.cause':
'La solicitud de aprobación caducó antes de que alguien respondiera.',
'conversations.toolFailure.approvalExpired.next':
'Vuelve a pedirlo para ejecutarlo: OpenHuman no lo reintentará por su cuenta.',
'conversations.toolFailure.unknown.cause': 'Algo salió mal con esta acción.',
'conversations.toolFailure.unknown.next':
'Inténtalo de nuevo; si sigue fallando, ejecuta el diagnóstico desde Configuración.',
+7
View File
@@ -66,6 +66,13 @@ const messages: TranslationMap = {
'conversations.toolFailure.timeout.cause': "L'action a pris trop de temps et a été arrêtée.",
'conversations.toolFailure.timeout.next':
"OpenHuman réessaiera, ou vous pouvez relancer l'action manuellement.",
'conversations.toolFailure.denied.cause': 'Vous avez refusé cette action.',
'conversations.toolFailure.denied.next':
"Rien à faire — elle n'a pas été exécutée. Redemandez si vous changez d'avis.",
'conversations.toolFailure.approvalExpired.cause':
"La demande d'approbation a expiré avant que quiconque réponde.",
'conversations.toolFailure.approvalExpired.next':
"Redemandez pour l'exécuter — OpenHuman ne réessaiera pas tout seul.",
'conversations.toolFailure.unknown.cause': 'Un problème est survenu avec cette action.',
'conversations.toolFailure.unknown.next':
"Réessayez ; si l'échec persiste, lancez le diagnostic depuis les Paramètres.",
+7
View File
@@ -63,6 +63,13 @@ const messages: TranslationMap = {
'conversations.toolFailure.timeout.cause': 'कार्य में बहुत अधिक समय लगा और इसे रोक दिया गया।',
'conversations.toolFailure.timeout.next':
'OpenHuman फिर से प्रयास करेगा, या आप इसे मैन्युअल रूप से दोबारा कर सकते हैं।',
'conversations.toolFailure.denied.cause': 'आपने इस क्रिया को अस्वीकार कर दिया।',
'conversations.toolFailure.denied.next':
'कुछ नहीं करना है — यह चलाई नहीं गई। मन बदलें तो फिर से कहें।',
'conversations.toolFailure.approvalExpired.cause':
'किसी के जवाब देने से पहले ही अनुमोदन अनुरोध की समय-सीमा समाप्त हो गई।',
'conversations.toolFailure.approvalExpired.next':
'इसे चलाने के लिए फिर से कहें — OpenHuman इसे स्वयं दोबारा नहीं आज़माएगा।',
'conversations.toolFailure.unknown.cause': 'इस कार्य में कुछ गड़बड़ हो गई।',
'conversations.toolFailure.unknown.next':
'दोबारा प्रयास करें; यदि यह बार-बार विफल हो, तो सेटिंग्स से डायग्नोस्टिक्स चलाएँ।',
+7
View File
@@ -65,6 +65,13 @@ const messages: TranslationMap = {
'conversations.toolFailure.timeout.cause': 'Tindakan memakan waktu terlalu lama dan dihentikan.',
'conversations.toolFailure.timeout.next':
'OpenHuman akan mencoba lagi, atau Anda dapat mengulanginya secara manual.',
'conversations.toolFailure.denied.cause': 'Anda menolak tindakan ini.',
'conversations.toolFailure.denied.next':
'Tidak ada yang perlu dilakukan — tindakan ini tidak dijalankan. Minta lagi jika Anda berubah pikiran.',
'conversations.toolFailure.approvalExpired.cause':
'Permintaan persetujuan kedaluwarsa sebelum ada yang merespons.',
'conversations.toolFailure.approvalExpired.next':
'Minta lagi untuk menjalankannya — OpenHuman tidak akan mencobanya sendiri.',
'conversations.toolFailure.unknown.cause': 'Terjadi kesalahan pada tindakan ini.',
'conversations.toolFailure.unknown.next':
'Coba lagi; jika terus gagal, jalankan diagnostik dari Pengaturan.',
+7
View File
@@ -66,6 +66,13 @@ const messages: TranslationMap = {
"L'azione ha richiesto troppo tempo ed è stata interrotta.",
'conversations.toolFailure.timeout.next':
'OpenHuman riproverà, oppure puoi riprovare manualmente.',
'conversations.toolFailure.denied.cause': 'Hai rifiutato questa azione.',
'conversations.toolFailure.denied.next':
'Niente da fare — non è stata eseguita. Richiedila di nuovo se cambi idea.',
'conversations.toolFailure.approvalExpired.cause':
'La richiesta di approvazione è scaduta prima che qualcuno rispondesse.',
'conversations.toolFailure.approvalExpired.next':
'Richiedila di nuovo per eseguirla — OpenHuman non riproverà da solo.',
'conversations.toolFailure.unknown.cause': 'Qualcosa è andato storto con questa azione.',
'conversations.toolFailure.unknown.next':
'Riprova; se continua a fallire, esegui la diagnostica dalle Impostazioni.',
+7
View File
@@ -60,6 +60,13 @@ const messages: TranslationMap = {
'conversations.toolFailure.timeout.cause': '작업이 너무 오래 걸려 중지되었습니다.',
'conversations.toolFailure.timeout.next':
'OpenHuman이 다시 시도하거나 수동으로 다시 실행할 수 있습니다.',
'conversations.toolFailure.denied.cause': '이 작업을 거부했습니다.',
'conversations.toolFailure.denied.next':
'할 일이 없습니다 — 실행되지 않았습니다. 마음이 바뀌면 다시 요청하세요.',
'conversations.toolFailure.approvalExpired.cause':
'아무도 응답하기 전에 승인 요청이 만료되었습니다.',
'conversations.toolFailure.approvalExpired.next':
'실행하려면 다시 요청하세요 — OpenHuman이 스스로 재시도하지 않습니다.',
'conversations.toolFailure.unknown.cause': '이 작업에서 문제가 발생했습니다.',
'conversations.toolFailure.unknown.next':
'다시 시도하세요. 계속 실패하면 설정에서 진단을 실행하세요.',
+7
View File
@@ -68,6 +68,13 @@ const messages: TranslationMap = {
'conversations.toolFailure.timeout.cause': 'Czynność trwała zbyt długo i została zatrzymana.',
'conversations.toolFailure.timeout.next':
'OpenHuman spróbuje ponownie lub możesz powtórzyć ją ręcznie.',
'conversations.toolFailure.denied.cause': 'Odrzuciłeś tę czynność.',
'conversations.toolFailure.denied.next':
'Nic do zrobienia — nie została wykonana. Poproś ponownie, jeśli zmienisz zdanie.',
'conversations.toolFailure.approvalExpired.cause':
'Prośba o zatwierdzenie wygasła, zanim ktokolwiek odpowiedział.',
'conversations.toolFailure.approvalExpired.next':
'Poproś ponownie, aby ją uruchomić — OpenHuman nie ponowi próby samodzielnie.',
'conversations.toolFailure.unknown.cause': 'Coś poszło nie tak z tą czynnością.',
'conversations.toolFailure.unknown.next':
'Spróbuj ponownie; jeśli nadal się nie udaje, uruchom diagnostykę w Ustawieniach.',
+7
View File
@@ -65,6 +65,13 @@ const messages: TranslationMap = {
'conversations.toolFailure.timeout.cause': 'A ação demorou demais e foi interrompida.',
'conversations.toolFailure.timeout.next':
'O OpenHuman tentará novamente, ou você pode tentar manualmente.',
'conversations.toolFailure.denied.cause': 'Você recusou esta ação.',
'conversations.toolFailure.denied.next':
'Nada a fazer — ela não foi executada. Peça novamente se mudar de ideia.',
'conversations.toolFailure.approvalExpired.cause':
'A solicitação de aprovação expirou antes que alguém respondesse.',
'conversations.toolFailure.approvalExpired.next':
'Peça novamente para executá-la — o OpenHuman não tentará de novo sozinho.',
'conversations.toolFailure.unknown.cause': 'Algo deu errado com esta ação.',
'conversations.toolFailure.unknown.next':
'Tente novamente; se continuar falhando, execute o diagnóstico nas Configurações.',
+7
View File
@@ -68,6 +68,13 @@ const messages: TranslationMap = {
'Действие заняло слишком много времени и было остановлено.',
'conversations.toolFailure.timeout.next':
'OpenHuman повторит попытку, или вы можете повторить её вручную.',
'conversations.toolFailure.denied.cause': 'Вы отклонили это действие.',
'conversations.toolFailure.denied.next':
'Ничего делать не нужно — оно не было выполнено. Попросите снова, если передумаете.',
'conversations.toolFailure.approvalExpired.cause':
'Срок запроса на подтверждение истёк, прежде чем кто-либо ответил.',
'conversations.toolFailure.approvalExpired.next':
'Попросите снова, чтобы выполнить его — OpenHuman не повторит попытку сам.',
'conversations.toolFailure.unknown.cause': 'С этим действием что-то пошло не так.',
'conversations.toolFailure.unknown.next':
'Повторите попытку; если ошибка повторяется, запустите диагностику в Настройках.',
+5
View File
@@ -50,6 +50,11 @@ const messages: TranslationMap = {
'请检查你的网络连接或模型设置;OpenHuman 将会重试。',
'conversations.toolFailure.timeout.cause': '操作耗时过长,已被停止。',
'conversations.toolFailure.timeout.next': 'OpenHuman 会重试,你也可以手动重试。',
'conversations.toolFailure.denied.cause': '你拒绝了此操作。',
'conversations.toolFailure.denied.next':
'无需任何操作——它没有被执行。如果你改变主意,请再次提出。',
'conversations.toolFailure.approvalExpired.cause': '在有人响应之前,审批请求已过期。',
'conversations.toolFailure.approvalExpired.next': '再次提出以执行它——OpenHuman 不会自行重试。',
'conversations.toolFailure.unknown.cause': '此操作出现了问题。',
'conversations.toolFailure.unknown.next': '请重试;如果持续失败,请在设置中运行诊断。',
'conversations.backgroundTasks.title': 'Background tasks',
@@ -114,7 +114,7 @@ function ToolGroupBlock({ summary, entries }: { summary: string; entries: ToolTi
}
/**
* The 8 failure classes the UI has localized copy for (#4254), keyed by the
* The failure classes the UI has localized copy for (#4254 / #4459), keyed by the
* camelCase form of the wire's PascalCase `class`. Any class not in this set
* falls back to the English `causePlain` / `nextAction` carried on the payload.
*/
@@ -126,6 +126,8 @@ const LOCALIZED_FAILURE_CLASSES: ReadonlySet<string> = new Set([
'blockedByPolicy',
'modelConnection',
'timeout',
'denied',
'approvalExpired',
'unknown',
]);
+5
View File
@@ -174,6 +174,9 @@ export interface SubagentToolCallEntry {
displayName?: string;
/** Server-computed contextual detail (path / recipient / query). */
detail?: string;
/** Plain-language explanation for a FAILED child call (#4459). Mirrors the
* parent {@link ToolTimelineEntry.failure}; absent on successful rows. */
failure?: ToolFailureExplanation;
}
/**
@@ -677,6 +680,8 @@ function subagentToolCallFromPersisted(call: PersistedSubagentToolCall): Subagen
outputChars: call.outputChars,
displayName: call.displayName,
detail: call.detail,
// Carry the persisted failure explanation across the round-trip (#4459).
failure: parseToolFailure(call.failure),
};
}
+4
View File
@@ -63,6 +63,10 @@ export interface PersistedSubagentToolCall {
displayName?: string;
/** Server-computed contextual detail (path / recipient / query). */
detail?: string;
/** Plain-language failure explanation for a FAILED child call (#4459).
* Mirrors the parent {@link PersistedToolTimelineEntry.failure}; absent on
* successful rows and on snapshots written before this field. */
failure?: PersistedToolFailure;
}
/**
+1
View File
@@ -586,6 +586,7 @@ async fn drain_progress(
output: _,
elapsed_ms,
iteration,
failure: _,
} => {
eprintln!(
"[harness_subagent_audit] progress turn={} subagent_tool_completed agent_id={} task_id={} tool={} call_id={} success={} output_chars={} elapsed_ms={} iteration={}",
+22 -2
View File
@@ -180,6 +180,20 @@ pub enum DomainEvent {
thread_id: String,
cancelled_request_id: String,
},
/// One or more queued steer/collect messages were delivered into a running
/// turn's steering handle (the harness applies them at the next iteration
/// checkpoint). Restores the delivery visibility the legacy
/// `RunQueueMessageDelivered` event provided before the TinyAgents migration
/// (issue #4456). `mode` is `"steer"` or `"collect"`.
RunQueueMessageDelivered {
thread_id: String,
mode: String,
delivered: usize,
},
/// Residual steer messages that the turn ended or was cancelled before
/// applying were drained back into the session run queue so they become the
/// next turn's input instead of silently vanishing (issue #4456).
RunQueueSteerRequeued { thread_id: String, requeued: usize },
// ── Monitor ───────────────────────────────────────────────────────
/// A background monitor changed lifecycle state.
@@ -1263,7 +1277,9 @@ impl DomainEvent {
| Self::OrchestrationSessionMessage { .. }
| Self::RunQueueMessageQueued { .. }
| Self::RunQueueFollowupDispatched { .. }
| Self::RunQueueInterrupted { .. } => "agent",
| Self::RunQueueInterrupted { .. }
| Self::RunQueueMessageDelivered { .. }
| Self::RunQueueSteerRequeued { .. } => "agent",
Self::MonitorStatusChanged { .. } | Self::MonitorLine { .. } => "monitor",
@@ -1427,6 +1443,8 @@ impl DomainEvent {
Self::RunQueueMessageQueued { .. } => "RunQueueMessageQueued",
Self::RunQueueFollowupDispatched { .. } => "RunQueueFollowupDispatched",
Self::RunQueueInterrupted { .. } => "RunQueueInterrupted",
Self::RunQueueMessageDelivered { .. } => "RunQueueMessageDelivered",
Self::RunQueueSteerRequeued { .. } => "RunQueueSteerRequeued",
Self::MonitorStatusChanged { .. } => "MonitorStatusChanged",
Self::MonitorLine { .. } => "MonitorLine",
Self::MemoryStored { .. } => "MemoryStored",
@@ -1573,7 +1591,9 @@ impl DomainEvent {
Self::WorkspaceViolation { path } => Some(path.as_str()),
Self::RunQueueMessageQueued { thread_id, .. }
| Self::RunQueueFollowupDispatched { thread_id, .. }
| Self::RunQueueInterrupted { thread_id, .. } => Some(thread_id.as_str()),
| Self::RunQueueInterrupted { thread_id, .. }
| Self::RunQueueMessageDelivered { thread_id, .. }
| Self::RunQueueSteerRequeued { thread_id, .. } => Some(thread_id.as_str()),
Self::MonitorStatusChanged { thread_id, .. } | Self::MonitorLine { thread_id, .. } => {
thread_id.as_deref()
}
-1
View File
@@ -9,7 +9,6 @@ Multi-agent orchestration domain. Owns the LLM tool-calling loop, sub-agent disp
- `pub fn run_subagent` / `pub struct SubagentRunOptions` / `pub enum SubagentRunError``harness/subagent_runner/` — execute a hierarchical sub-agent from a parent tool loop.
- `pub struct AgentDefinition` / `pub struct AgentDefinitionRegistry` / `pub enum SandboxMode` / `pub enum ToolScope``harness/definition.rs` — sub-agent archetypes loaded from built-ins + workspace TOML.
- `pub mod harness::fork_context``harness/fork_context.rs` — task-local parent context for KV-cache reuse.
- `pub mod harness::interrupt` (`check_interrupt`, `InterruptFence`, `InterruptedError`) — `harness/interrupt.rs` — graceful cancellation primitives.
- `pub trait ToolDispatcher` / `pub struct ParsedToolCall` / `pub struct ToolExecutionResult``dispatcher.rs:14-50` — pluggable tool-call format (XML / JSON / P-Format).
- `pub mod triage` (`run_triage`, `apply_decision`, `TriggerEnvelope`, `TriageDecision`, `TriageAction`) — `triage/mod.rs:34-45` — classify external triggers, escalate to sub-agents.
- `pub mod prompts::SystemPromptBuilder``prompts/` — system-prompt section composer.
@@ -62,6 +62,11 @@ pub struct AgentTurnRequest {
pub provider_label: String,
pub(crate) handoff_cache:
Option<Arc<crate::openhuman::agent::harness::subagent_runner::ResultHandoffCache>>,
/// Agent-level TokenJuice compaction profile
/// (`definition.effective_tokenjuice_compression()`), threaded into the
/// sub-agent `TurnContextMiddleware` so tool outputs compact like the chat
/// path instead of taking a blunt byte-cap truncation (#4466).
pub tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression,
}
/// Token/cost totals a custom runner reports back. Mirrors the runner's internal
@@ -85,6 +90,9 @@ pub struct AgentTurnResult {
pub early_exit_tool: Option<String>,
/// `true` when the run stopped at the model-call cap with work still pending.
pub hit_cap: bool,
/// Set (with the halt reason) when the repeated-failure / repeat-progress
/// circuit breaker halted the run; the runner reports `Incomplete` (#4466).
pub breaker_halt: Option<String>,
}
/// A per-agent custom turn-graph runner: given the assembled [`AgentTurnRequest`],
+72 -31
View File
@@ -1,50 +1,91 @@
use regex::Regex;
use std::sync::LazyLock;
/// Key/value credential shapes: `token: "…"`, `api_key=…`, `bearer: …`, etc.
static SENSITIVE_KV_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\.]{8,}))"#).unwrap()
});
/// Bare AWS access-key IDs — `AKIA…`/`ASIA…` followed by 16 base32 chars — which
/// appear naked in env dumps and config reads with no surrounding key name.
static AWS_ACCESS_KEY_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b((?:AKIA|ASIA)[0-9A-Z]{16})\b").unwrap());
/// Bare OpenAI-style secret keys — `sk-…` (incl. `sk-proj-…`) with a long token
/// body. Not necessarily attached to a `key:` label in raw API responses.
static OPENAI_KEY_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b(sk-[A-Za-z0-9_\-]{16,})\b").unwrap());
/// Space-separated bearer tokens as they appear in HTTP auth headers
/// (`Authorization: Bearer <token>`) — the KV regex only catches `bearer:`/`=`.
static BEARER_SPACE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\b(Bearer)\s+([A-Za-z0-9_\-\.=+/]{16,})").unwrap());
/// Preserve the first 4 chars of `val` for context, returning the redacted
/// prefix (empty when the value is too short to safely reveal any of it).
fn redact_prefix(val: &str) -> &str {
if val.chars().count() > 4 {
match val.char_indices().nth(4) {
Some((idx, _)) => &val[..idx],
None => val,
}
} else {
""
}
}
/// Scrub credentials from tool output to prevent accidental exfiltration.
/// Replaces known credential patterns with a redacted placeholder while preserving
/// a small prefix for context.
///
/// Covers labelled key/value pairs plus bare secrets that show up unlabelled in
/// env dumps, config reads and API responses: AWS access-key IDs (`AKIA…`/
/// `ASIA…`), OpenAI-style `sk-…` keys, and space-separated `Bearer <token>`
/// auth headers.
pub(crate) fn scrub_credentials(input: &str) -> String {
SENSITIVE_KV_REGEX
.replace_all(input, |caps: &regex::Captures| {
let full_match = &caps[0];
let key = &caps[1];
let val = caps
.get(2)
.or(caps.get(3))
.or(caps.get(4))
.map(|m| m.as_str())
.unwrap_or("");
let stage_kv = SENSITIVE_KV_REGEX.replace_all(input, |caps: &regex::Captures| {
let full_match = &caps[0];
let key = &caps[1];
let val = caps
.get(2)
.or(caps.get(3))
.or(caps.get(4))
.map(|m| m.as_str())
.unwrap_or("");
// Preserve first 4 chars for context, then redact
let prefix = if val.chars().count() > 4 {
match val.char_indices().nth(4) {
Some((idx, _)) => &val[..idx],
None => val,
}
} else {
""
};
let prefix = redact_prefix(val);
if full_match.contains(':') {
if full_match.contains('"') {
format!("\"{}\": \"{}*[REDACTED]\"", key, prefix)
} else {
format!("{}: {}*[REDACTED]", key, prefix)
}
} else if full_match.contains('=') {
if full_match.contains('"') {
format!("{}=\"{}*[REDACTED]\"", key, prefix)
} else {
format!("{}={}*[REDACTED]", key, prefix)
}
if full_match.contains(':') {
if full_match.contains('"') {
format!("\"{}\": \"{}*[REDACTED]\"", key, prefix)
} else {
format!("{}: {}*[REDACTED]", key, prefix)
}
} else if full_match.contains('=') {
if full_match.contains('"') {
format!("{}=\"{}*[REDACTED]\"", key, prefix)
} else {
format!("{}={}*[REDACTED]", key, prefix)
}
} else {
format!("{}: {}*[REDACTED]", key, prefix)
}
});
// Bare AWS access-key IDs: keep the 4-char `AKIA`/`ASIA` prefix for context.
let stage_aws = AWS_ACCESS_KEY_REGEX.replace_all(&stage_kv, |caps: &regex::Captures| {
format!("{}*[REDACTED]", redact_prefix(&caps[1]))
});
// Bare `sk-…` keys: keep the `sk-` scheme, redact the secret body.
let stage_openai = OPENAI_KEY_REGEX.replace_all(&stage_aws, |_caps: &regex::Captures| {
"sk-*[REDACTED]".to_string()
});
// Space-separated `Bearer <token>`: keep the scheme word, redact the token.
BEARER_SPACE_REGEX
.replace_all(&stage_openai, |caps: &regex::Captures| {
format!("{} *[REDACTED]", &caps[1])
})
.to_string()
}
+16 -4
View File
@@ -55,10 +55,18 @@ pub(crate) async fn run_channel_turn_via_graph(
) -> Result<String> {
let extra_arc = Arc::new(extra_tools);
// The callable set is the visibility whitelist (empty = every tool visible
// across the registry + per-turn extras). The runner advertises each via its
// own `spec()`, deduped by name (extras shadow the registry).
let allowed = visible_tool_names.cloned().unwrap_or_default();
// The callable set is the visibility whitelist. The runner advertises each via
// its own `spec()`, deduped by name (extras shadow the registry).
// Fail-closed allowlist plumbing (issue #4452): the shared seam takes an
// `Option<HashSet<String>>` where `None` = no filter (all visible tools) and
// `Some(set)` = exactly those tools. The channel/CLI path's historical
// convention is "no filter / empty set = every visible tool", so map both a
// missing filter and an empty set to `None`; only a populated set is treated
// as an explicit whitelist.
let allowed: Option<HashSet<String>> = match visible_tool_names {
Some(set) if !set.is_empty() => Some(set.clone()),
_ => None,
};
// Capture native-tool support before `provider` is moved into the runner: the
// durable history append below serializes this turn's typed suffix with the
@@ -132,6 +140,10 @@ pub(crate) async fn run_channel_turn_via_graph(
None,
// Interactive channel/CLI turn — never serve a cached model response.
false,
// #4457 (defect C): the channel/CLI path has no post-run wrap-up and does
// NOT emit `TurnCompleted` itself, so let the seam emit the single
// terminal event (legacy-engine parity).
false,
)
.await?;
// Append only this turn's typed suffix (assistant tool-calls + tool results +
@@ -77,6 +77,17 @@ pub fn classify_memory_op(tool_name: &str, arguments: &serde_json::Value) -> Mem
// Durable mutations: create an entry, delete an entry, or ingest a
// document into the memory tree (the split-out ingest tool).
"memory_store" | "memory_forget" | "memory_tree_ingest_document" => MemoryOp::Write,
// `remember_preference` / `save_preference` (#4458): these DO persist via
// `Memory::store`, but into dedicated preference namespaces
// (`pinned_preferences` / `user_pref_{general,situational}`) that are
// surfaced by direct system-prompt injection or per-query recall — they
// bypass the inference/stability pipeline and are NOT part of the
// `MEMORY.md` curated wiki the archivist reconciles. So they are neither
// an index write that needs a dedupe read nor one that must be closed by
// `update_memory_md`; treating them as `Write` would resurrect the very
// unsatisfiable "call update_memory_md" nag loop this issue removes.
// Classified `Other` deliberately (explicit arm, not fall-through).
"remember_preference" | "save_preference" => MemoryOp::Other,
// Consolidated memory_tree tool: `ingest_document` writes; every other
// mode is a read-only retrieval.
"memory_tree" => match arg_str("mode") {
+2 -2
View File
@@ -24,7 +24,7 @@
pub mod agent_graph;
pub mod archivist;
pub(crate) mod builtin_definitions;
mod credentials;
pub(crate) mod credentials;
pub mod definition;
pub(crate) mod definition_loader;
pub mod fork_context;
@@ -62,7 +62,7 @@ pub use task_recency_context::{current_task_recency_window, with_task_recency_wi
pub(crate) use graph::run_channel_turn_via_graph;
pub(crate) use instructions::build_tool_instructions_filtered;
pub(crate) use parse::parse_tool_calls;
pub(crate) use parse::{parse_tool_calls, parse_tool_calls_with_pformat};
#[cfg(test)]
mod harness_gap_tests;
+100 -1
View File
@@ -5,7 +5,7 @@ use crate::openhuman::tools::Tool;
use regex::Regex;
use std::sync::LazyLock;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub(crate) struct ParsedToolCall {
pub name: String,
pub arguments: serde_json::Value,
@@ -708,6 +708,105 @@ pub(crate) fn parse_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>)
(text_parts.join("\n"), calls)
}
/// P-Format-aware wrapper over [`parse_tool_calls`] (issue #4465).
///
/// The migrated tinyagents parse path
/// (`crate::openhuman::tinyagents::model`) kept the XML/JSON/markdown/GLM
/// grammars but dropped the legacy **P-Format** positional grammar
/// (`<tool_call>name[arg1|arg2]</tool_call>`) — even though `PFormat` is the
/// default [`ToolCallFormat`](crate::openhuman::context::prompt::ToolCallFormat)
/// and ~10 builtin agent prompts still *teach* the `name[a|b]` form. A model
/// that followed its own instructions therefore emitted calls that
/// [`parse_tool_calls`] logged as "malformed `<tool_call>` JSON" and silently
/// dropped, so the turn continued as if no tool was called.
///
/// This restores parity by walking the `<tool_call>`-family tags and, for each
/// tag body, **preferring** the registry-driven P-Format parse
/// ([`pformat::parse_call`](crate::openhuman::agent::pformat::parse_call)) and
/// **falling back** to the JSON entry the canonical parser produced at the same
/// ordinal position — the exact per-tag selection the legacy
/// `PFormatToolDispatcher` performed. This makes it a strict superset of
/// [`parse_tool_calls`]:
///
/// - An **empty** `registry` (native/JSON agents advertise no positional
/// layout, or no tools at all) short-circuits to [`parse_tool_calls`], so
/// nothing changes for non-PFormat callers.
/// - A tag body that is not a valid `name[...]` positional call (e.g. a JSON
/// `{"name":..}` body, or an unregistered tool name) leaves
/// [`pformat::parse_call`](crate::openhuman::agent::pformat::parse_call)
/// returning `None`, so the canonical JSON entry is used unchanged.
pub(crate) fn parse_tool_calls_with_pformat(
response: &str,
registry: &crate::openhuman::agent::pformat::PFormatRegistry,
) -> (String, Vec<ParsedToolCall>) {
// Canonical parse first: narrative text + JSON/XML/markdown/GLM calls.
let (narrative, json_calls) = parse_tool_calls(response);
// Without a registry there is no positional layout to reconstruct — keep
// the canonical result verbatim (behaviour-neutral for non-PFormat paths).
if registry.is_empty() {
return (narrative, json_calls);
}
// Walk the tags ourselves, preferring a P-Format body per tag and falling
// back to the JSON entry the canonical parser produced at the same ordinal
// position (both walk the same ordered set of `<tool_call>`-family tags).
let mut combined: Vec<ParsedToolCall> = Vec::new();
let mut json_idx = 0usize;
let mut remaining = response;
while !remaining.is_empty() {
let Some((open_idx, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) else {
break;
};
let Some(close_tag) = matching_tool_call_close_tag(open_tag) else {
break;
};
let after_open = &remaining[open_idx + open_tag.len()..];
let Some(close_idx) = after_open.find(close_tag) else {
break;
};
let body = &after_open[..close_idx];
if let Some((name, arguments)) =
crate::openhuman::agent::pformat::parse_call(body, registry)
{
// Do NOT log the arguments — a p-format body carries tool arguments
// that may contain user data (bug-report-2026-05-26 A3 parity).
tracing::debug!(
tool = name.as_str(),
"[agent_parse] recovered P-Format tool call (name[arg|arg]) the JSON pass dropped"
);
combined.push(ParsedToolCall {
name,
arguments,
id: None,
});
// Do NOT advance `json_idx` here: a P-Format tag is one the JSON pass
// could not parse, so `parse_tool_calls` produced no `json_calls`
// entry for it. Advancing would shift every later JSON tag onto the
// wrong `json_calls` index and silently drop a real JSON call.
} else if let Some(json_call) = json_calls.get(json_idx) {
combined.push(json_call.clone());
json_idx += 1;
}
remaining = &after_open[close_idx + close_tag.len()..];
}
if combined.is_empty() {
// No `<tool_call>` tag recovered a positional call — the canonical
// result already covers JSON/XML/markdown/GLM grammars.
return (narrative, json_calls);
}
tracing::debug!(
parsed_tool_calls = combined.len(),
"[agent_parse] P-Format-aware parse produced combined tool-call set"
);
(narrative, combined)
}
#[cfg(test)]
pub(crate) fn parse_structured_tool_calls(tool_calls: &[ToolCall]) -> Vec<ParsedToolCall> {
tool_calls
@@ -75,7 +75,13 @@ fn tool_records_from_conversation(
if let ConversationMessage::AssistantToolCalls { tool_calls, .. } = msg {
for call in tool_calls {
let outcome = tool_outcomes.iter().find(|o| o.call_id == call.id);
let success = outcome.map(|o| o.success).unwrap_or(true);
// Default a MISSING outcome to `false` (#4467, item 7): a call
// with no captured outcome is a hallucinated/unknown tool the
// crate recovered via `ReturnToolError` without running
// `after_tool` (so the capture sink never saw it). Recording it as
// succeeded misreports the timeline; real executed tools always
// have an outcome, so this only flips the genuinely-unknown case.
let success = outcome.map(|o| o.success).unwrap_or(false);
let output_summary = outcome
.map(|o| hooks::sanitize_tool_output(&o.content, &call.name, success))
.unwrap_or_default();
@@ -689,10 +695,13 @@ impl Agent {
// read the parent's provider, tools, model, and workspace via
// the PARENT_CONTEXT task-local.
// Arm the thread-goal budget stop hook for this turn when an active,
// budgeted goal exists — it hard-stops the loop the moment running usage
// would exceed the cap (so an autonomous run can't blow past it between
// accounting points). Merge with any ambient stop hooks rather than
// clobbering them. No budgeted active goal → no extra hook, no wrap.
// budgeted goal exists — it votes to stop the loop as soon as running
// usage would exceed the cap. #4469 item 1: the stop is a graceful pause
// drained at the next iteration boundary, not an instantaneous abort, so
// the current tool round + one wrap-up summary call can still run past the
// cap (a small, bounded overshoot) before the partial transcript returns.
// Merge with any ambient stop hooks rather than clobbering them. No
// budgeted active goal → no extra hook, no wrap.
let mut turn_stop_hooks = crate::openhuman::agent::stop_hooks::current_stop_hooks();
if let Some(ref goal) = active_goal {
if let Some(hook) =
@@ -900,6 +909,9 @@ impl Agent {
// Progressive-disclosure handoff is a sub-agent (integrations_agent)
// concern; the top-level chat turn never sets it.
handoff: None,
// Live transcript snapshotting is a sub-agent error-recovery concern
// (#4466); the chat path persists its transcript post-run.
transcript_snapshot: None,
};
// Gather any sub-agent spend delegated during this turn (synchronous
@@ -989,6 +1001,28 @@ impl Agent {
// A completion with no text and no tool calls is never a valid final
// answer — surface it as an error instead of wedging the thread on a
// blank reply (bug-report-2026-05-26 A1, defect B).
//
// #4457 (defect A): the empty terminal assistant response was already
// folded into `self.history` via `outcome.conversation` at the
// `history.extend` above (an empty `Chat(assistant(""))`). The #4093
// branch below pops that dangling blank row before re-prompting, but
// this `tool_calls == 0` path returned the error with the empty row
// still in history — so the *next* request carried an empty-content
// assistant message and strict providers (Anthropic: "text content
// blocks must be non-empty") 400 the whole thread, not just this turn.
// Pop the trailing empty assistant row before returning so a retry
// sends a clean transcript.
if matches!(
self.history.last(),
Some(ConversationMessage::Chat(msg))
if msg.role == "assistant" && msg.content.trim().is_empty()
) {
log::debug!(
"[agent_loop] EmptyProviderResponse at iteration {}: popping dangling empty assistant row before returning — #4457 defect A",
outcome.model_calls
);
self.history.pop();
}
return Err(anyhow::Error::new(
crate::openhuman::agent::error::AgentError::EmptyProviderResponse {
iteration: outcome.model_calls,
@@ -1122,14 +1156,32 @@ impl Agent {
.await;
// Content (prompt + reply) rides its own event so a tracing consumer can
// attach it to the turn span. Transmission off-device stays gated by the
// exporter's opt-in `capture_content` flag; here it is only surfaced onto
// the in-memory span.
self.emit_progress(AgentProgress::TurnContent {
input: Some(user_message.to_string()),
output: Some(reply.clone()),
})
.await;
// attach it to the turn span. Gated on the opt-in
// `observability.agent_tracing.capture_content` flag (#4454): with the
// default off, we don't even emit the content event, so prompt/reply text
// never reaches the span store or any exporter. The collector applies the
// same storage-level gate as defense in depth.
let capture_content = self
.integration_runtime_config
.as_ref()
.map(|c| c.observability.agent_tracing.capture_content)
.unwrap_or(false);
if capture_content {
log::debug!(
target: "agent-tracing",
"[agent-tracing] emitting TurnContent (capture_content=true)"
);
self.emit_progress(AgentProgress::TurnContent {
input: Some(user_message.to_string()),
output: Some(reply.clone()),
})
.await;
} else {
log::debug!(
target: "agent-tracing",
"[agent-tracing] skipping TurnContent emit (capture_content=false)"
);
}
self.emit_progress(AgentProgress::TurnCompleted {
iterations: outcome.model_calls as u32,
@@ -79,13 +79,23 @@ pub(crate) struct ChatTurnGraph {
/// ([`core`](super::core) folds usage, persists the conversation, and handles a
/// cap-hit checkpoint).
pub(crate) async fn run_chat_turn_graph(graph: ChatTurnGraph) -> Result<TinyagentsTurnOutcome> {
// Fail-closed allowlist plumbing (issue #4452): the shared seam now takes an
// `Option<HashSet<String>>` where `None` = no filter (all visible tools) and
// `Some(set)` = exactly those tools. The chat path's historical convention is
// "empty `visible_tool_names` = every visible tool", so map an empty set to
// `None` to preserve that behavior; a populated set stays an explicit filter.
let visible_tool_names = if graph.visible_tool_names.is_empty() {
None
} else {
Some(graph.visible_tool_names)
};
run_turn_via_tinyagents_shared(
graph.provider,
&graph.model,
graph.temperature,
graph.messages,
vec![graph.tools],
graph.visible_tool_names,
visible_tool_names,
graph.max_iterations,
// Mirror the harness event stream onto this session's progress sink.
graph.on_progress,
@@ -112,6 +122,12 @@ pub(crate) async fn run_chat_turn_graph(graph: ChatTurnGraph) -> Result<Tinyagen
// Interactive chat turn — response caching MUST stay off so a live user
// turn is never served a cached model response (correctness/safety).
false,
// #4457 (defect C): defer the terminal `TurnCompleted` to the caller.
// The session path (`run_turn_impl` in `turn/core.rs`) runs its cap/#4093
// wrap-up (`summarize_turn_wrapup`) *after* this seam returns and then
// emits the single `TurnCompleted` itself — a seam-level emit here would
// fire before that checkpoint streams and duplicate the event.
true,
)
.await
}
@@ -27,3 +27,30 @@ pub fn autonomous_iter_cap() -> Option<usize> {
pub async fn with_autonomous_iter_cap<F: Future>(cap: usize, fut: F) -> F::Output {
AUTONOMOUS_ITER_CAP.scope(cap, fut).await
}
/// Lift a sub-agent's per-agent iteration `base` to the active autonomous cap
/// when one is in scope (issue #4463).
///
/// Autonomous task/skill runs (`task_dispatcher` / `skill_runtime`) scope an
/// [`with_autonomous_iter_cap`] of `TASK_RUN_MAX_ITERATIONS` /
/// `WORKFLOW_RUN_MAX_ITERATIONS` around the whole tree so an unattended run
/// continues until it's done or a circuit breaker trips, rather than stopping at
/// a specialist sub-agent's normal cap (e.g. 10). The migration to the tinyagents
/// harness dropped every reader of [`autonomous_iter_cap`], so those setters
/// became dead knobs and sub-agents silently reverted to the normal cap. This is
/// the restored reader: sub-agent iteration computations run their
/// `effective_max_iterations()` through it so the lift takes effect again. The
/// cost budget + repeated-failure breakers remain the primary runaway guards.
pub fn subagent_iter_cap_with_autonomous_lift(base: usize) -> usize {
match autonomous_iter_cap() {
Some(cap) if cap > base => {
tracing::debug!(
base,
autonomous_cap = cap,
"[subagent_runner:autonomous] lifting sub-agent iteration cap for autonomous run"
);
cap
}
_ => base,
}
}
@@ -46,7 +46,9 @@ mod tool_prep;
mod types;
// Public API — the entry point and the shapes it returns.
pub use autonomous::{autonomous_iter_cap, with_autonomous_iter_cap};
pub use autonomous::{
autonomous_iter_cap, subagent_iter_cap_with_autonomous_lift, with_autonomous_iter_cap,
};
pub use ops::run_subagent;
pub use types::{
SubagentCheckpointData, SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome,
@@ -37,6 +37,7 @@ use crate::openhuman::agent::harness::subagent_runner::types::SubagentRunError;
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage, Provider};
use crate::openhuman::tinyagents::{run_turn_via_tinyagents_shared, SubagentScope};
use crate::openhuman::tokenjuice::AgentTokenjuiceCompression;
use crate::openhuman::tools::{Tool, ToolSpec};
use tinyagents::harness::workspace::WorkspaceDescriptor;
@@ -80,33 +81,36 @@ pub(crate) async fn run_agent_turn_request_via_default_graph(
transcript_stem,
provider_label,
handoff_cache,
tokenjuice_compression,
} = req;
let (output, iterations, usage, early_exit_tool, hit_cap) = run_subagent_via_graph(
provider,
&model,
temperature,
&mut history,
parent_tools,
dynamic_tools,
specs,
allowed_names,
max_iterations,
run_queue,
on_progress,
&agent_id,
&task_id,
extended_policy,
worker_thread_id,
workspace_dir,
workspace_descriptor,
max_output_tokens,
model_vision,
&transcript_stem,
&provider_label,
handoff_cache,
)
.await?;
let (output, iterations, usage, early_exit_tool, hit_cap, breaker_halt) =
run_subagent_via_graph(
provider,
&model,
temperature,
&mut history,
parent_tools,
dynamic_tools,
specs,
allowed_names,
max_iterations,
run_queue,
on_progress,
&agent_id,
&task_id,
extended_policy,
worker_thread_id,
workspace_dir,
workspace_descriptor,
max_output_tokens,
model_vision,
&transcript_stem,
&provider_label,
handoff_cache,
tokenjuice_compression,
)
.await?;
Ok(AgentTurnResult {
history,
@@ -120,6 +124,7 @@ pub(crate) async fn run_agent_turn_request_via_default_graph(
},
early_exit_tool,
hit_cap,
breaker_halt,
})
}
@@ -161,7 +166,25 @@ pub(super) async fn run_subagent_via_graph(
handoff_cache: Option<
std::sync::Arc<crate::openhuman::agent::harness::subagent_runner::ResultHandoffCache>,
>,
) -> Result<(String, usize, AggregatedUsage, Option<String>, bool), SubagentRunError> {
// Agent-level TokenJuice profile (`definition.effective_tokenjuice_compression()`,
// #4466). Threaded into the sub-agent `TurnContextMiddleware` so sub-agent
// tool outputs get the same content-aware compaction the chat path applies
// instead of a blunt byte-cap truncation.
tokenjuice_compression: AgentTokenjuiceCompression,
) -> Result<
(
String,
usize,
AggregatedUsage,
Option<String>,
bool,
// Breaker-halt reason (#4466): `Some` when the repeated-failure /
// repeat-progress circuit breaker stopped the run; the caller reports
// `Incomplete` instead of `Completed`.
Option<String>,
),
SubagentRunError,
> {
tracing::info!(
model,
max_iterations,
@@ -212,6 +235,25 @@ pub(super) async fn run_subagent_via_graph(
// call rather than relying solely on the parent's one-time trim.
let context_window = provider.effective_context_window(model).await;
// Build the sub-agent's context middleware from the live `[context]` config +
// the agent's TokenJuice profile (#4466), matching how the chat path wires
// `TurnContextMiddleware` (session/turn/core.rs). The migrated sub-agent path
// had regressed to `TurnContextMiddleware::defaults()` — compression Off — so
// sub-agent tool outputs took a blunt 16 KiB truncation instead of the
// content-aware TokenJuice compaction the definition asked for. Honor the
// `[context]` enabled / autocompact opt-outs, microcompact keep-recent, and
// per-result byte budget too, so a sub-agent turn compacts like a chat turn.
let context_mw = build_subagent_context_mw(tokenjuice_compression).await;
// Live transcript snapshot sink (#4466): the harness owns the working message
// vector and drops it on a mid-run `Err`, so a failed sub-agent run used to
// persist NOTHING (breaking `learning/transcript_ingest`) and leave an empty
// worker thread. Attach a snapshot middleware that mirrors each `before_model`
// request's transcript here, so the error path below can still persist the
// rounds that completed before the failure.
let transcript_snapshot: crate::openhuman::tinyagents::TranscriptSnapshotSink =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
// A sub-agent turn runs *nested inside* the parent agent's turn (parent
// harness → spawn_subagent tool → here), so the child's full
// `run_turn_via_tinyagents_shared` future would otherwise sit on the parent's
@@ -220,7 +262,7 @@ pub(super) async fn run_subagent_via_graph(
// Capture native-tool support before `provider` is moved: the durable-history
// append below serializes this turn's typed suffix with the matching dispatcher.
let native_tools = provider.supports_native_tools();
let mut outcome = Box::pin(run_turn_via_tinyagents_shared(
let run_result = Box::pin(run_turn_via_tinyagents_shared(
provider,
model,
temperature,
@@ -232,7 +274,13 @@ pub(super) async fn run_subagent_via_graph(
// adapter resolves a name by scanning the sets in order, so a
// parent-first order would run the parent impl for a shadowed name.
vec![Arc::new(dynamic_tools), parent_tools],
allowed_names,
// Fail-closed (issue #4452): a sub-agent ALWAYS carries a concrete,
// resolved allowlist (`allowed_names`), so pass it as `Some(..)`. An empty
// set is therefore a genuine deny-all — a tool-less agent
// (`ToolScope::Named([])`), a zero-match `skill_filter`, or a `named` list
// that resolved to nothing registers ZERO tools instead of implicitly
// inheriting the parent's full surface (shell/file-write/spawn).
Some(allowed_names),
max_iterations,
// Parent's progress sink — child events ride it, scoped below.
on_progress,
@@ -248,11 +296,12 @@ pub(super) async fn run_subagent_via_graph(
true,
// Bound the sub-agent's per-call output at its configured budget.
Some(max_output_tokens),
// Context middlewares: cache-align + default tool-result byte cap so a
// sub-agent's (often large) tool outputs stay bounded in its transcript,
// plus the progressive-disclosure handoff when a cache is attached.
// Context middlewares (#4466): config-sourced TokenJuice compaction +
// tool-result byte cap + microcompact + summarization opt-outs (built
// above), plus the progressive-disclosure handoff when a cache is
// attached, plus the live transcript-snapshot sink for error recovery.
{
let mut mw = crate::openhuman::tinyagents::TurnContextMiddleware::defaults();
let mut mw = context_mw;
if let Some(cache) = handoff_cache {
mw.handoff = Some(crate::openhuman::tinyagents::HandoffConfig {
cache,
@@ -260,6 +309,7 @@ pub(super) async fn run_subagent_via_graph(
task_id: task_id.to_string(),
});
}
mw.transcript_snapshot = Some(transcript_snapshot.clone());
mw
},
// Sub-agents gate via their own SubagentToolSource policy path, not the
@@ -270,9 +320,50 @@ pub(super) async fn run_subagent_via_graph(
// Sub-agent turns run tools with external effects; not a deterministic
// internal run, so response caching stays off (safe default).
false,
// #4457 (defect C): irrelevant for sub-agents — they carry a
// `subagent_scope`, so the seam never emits a top-level `TurnCompleted`
// (they report via `Subagent*` events). Pass `false` for clarity.
false,
))
.await
.map_err(map_tinyagents_subagent_error)?;
.await;
let mut outcome = match run_result {
Ok(outcome) => outcome,
Err(err) => {
// #4466: the harness dropped its partial transcript, but the snapshot
// middleware mirrored every completed round. Persist those rounds to
// `session_raw` (so `learning/transcript_ingest` can still read a
// failed run) and mirror them onto the worker thread, THEN surface the
// error. Previously the `?`-return skipped both persistence steps, so
// a failed run left no transcript and an empty worker thread.
let mapped = map_tinyagents_subagent_error(err);
let recovered = transcript_snapshot
.lock()
.map(|g| g.clone())
.unwrap_or_default();
tracing::warn!(
agent_id,
task_id,
error = %mapped,
recovered_rounds = recovered.len(),
"[subagent_runner:graph] sub-agent run errored; persisting recovered transcript before returning (#4466)"
);
persist_failed_run(
&workspace_dir,
transcript_stem,
agent_id,
task_id,
provider_label,
model,
&recovered,
context_window.unwrap_or(0),
if native_tools { "native" } else { "xml" },
worker_thread_id.as_deref(),
&mapped,
);
return Err(mapped);
}
};
// Write the final conversation back so the caller can checkpoint / persist.
// Keep the original (un-expanded) prior turns and append only this turn's typed
@@ -307,21 +398,65 @@ pub(super) async fn run_subagent_via_graph(
// checkpoint (the delegating agent continues from partial progress) rather
// than surfacing an empty/partial answer — the legacy `SubagentCheckpoint`.
if outcome.hit_cap {
let digest = build_cap_digest(&outcome.conversation);
let digest = build_cap_digest(&outcome.conversation, &outcome.tool_outcomes);
let strategy = super::checkpoint::SubagentCheckpoint {
provider: summary_provider.as_ref(),
model: model.to_string(),
temperature,
agent_id: agent_id.to_string(),
// The checkpoint summary call's output cap — the standard per-turn
// budget (the value this field replaced when it was hardcoded).
max_output_tokens: crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS,
// The checkpoint summary call's output cap. #4469 item 5: honour this
// sub-agent definition's own per-call output budget (the same
// `max_output_tokens` bounding every task model call above) instead of
// the process-global `AGENT_TURN_MAX_OUTPUT_TOKENS` floor, so a
// definition that raised or lowered its output cap is respected by the
// cap-summary call too.
max_output_tokens,
};
match strategy.summarize_cap_hit(&digest, max_iterations).await {
Ok(co) => {
if let Some(u) = co.usage {
// Fold ALL four token fields (the legacy cap-summary folded
// cached tokens too, not just input/output), then price the
// call and feed the global cost tracker directly (#4467,
// item 2). The checkpoint summary call bypasses the harness so
// the observability bridge never sees it — without this record
// its cached tokens are lost and it costs $0 in the footer /
// transcript meta / cost dashboard.
usage.input_tokens += u.input_tokens;
usage.output_tokens += u.output_tokens;
usage.cached_input_tokens += u.cached_input_tokens;
let call_cost =
if u.charged_amount_usd.is_finite() && u.charged_amount_usd > 0.0 {
u.charged_amount_usd
} else {
crate::openhuman::cost::catalog::estimate_cost_usd(
model,
u.input_tokens,
u.output_tokens,
u.cached_input_tokens,
)
};
usage.charged_amount_usd += call_cost;
crate::openhuman::cost::record_provider_usage(
model,
&crate::openhuman::inference::provider::UsageInfo {
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
context_window: u.context_window,
cached_input_tokens: u.cached_input_tokens,
cache_creation_tokens: u.cache_creation_tokens,
reasoning_tokens: u.reasoning_tokens,
charged_amount_usd: call_cost,
},
);
tracing::debug!(
agent_id,
input_tokens = u.input_tokens,
output_tokens = u.output_tokens,
cached_input_tokens = u.cached_input_tokens,
call_cost,
"[subagent] cap-hit summary call folded + priced + recorded into cost tracker (#4467, item 2)"
);
}
outcome.text = co.text;
}
@@ -398,9 +533,60 @@ pub(super) async fn run_subagent_via_graph(
usage,
outcome.early_exit_tool,
outcome.hit_cap,
// #4466: propagate a circuit-breaker halt so the runner reports Incomplete.
outcome.breaker_halt,
))
}
/// Build the sub-agent turn's [`TurnContextMiddleware`] from the live
/// `[context]` config and the agent's TokenJuice profile (#4466), mirroring the
/// chat path (`session/turn/core.rs`). Falls back to
/// [`TurnContextMiddleware::defaults`] when the config can't be loaded so a
/// config glitch degrades to the safe (byte-cap-only) behavior rather than
/// erroring the run.
async fn build_subagent_context_mw(
tokenjuice_compression: AgentTokenjuiceCompression,
) -> crate::openhuman::tinyagents::TurnContextMiddleware {
let mut mw = crate::openhuman::tinyagents::TurnContextMiddleware::defaults();
// Always thread the agent's compression profile — even on the config-default
// path — so the definition's TokenJuice choice is honored.
mw.tokenjuice_compression = tokenjuice_compression;
match crate::openhuman::config::Config::load_or_init().await {
Ok(config) => {
let ctx = &config.context;
// TokenJuice content-aware compaction gates on the same master
// `[context].compaction_enabled` the chat path reads
// (`ContextManager::compaction_enabled`).
mw.tokenjuice_compaction_enabled = ctx.compaction_enabled;
mw.tool_result_budget_bytes = ctx.tool_result_budget_bytes;
// Microcompact keep-recent is `0` (disabled) unless microcompact is on.
mw.microcompact_keep_recent = if ctx.microcompact_enabled {
ctx.microcompact_keep_recent
} else {
0
};
// Summarization step honors the `[context].enabled` + autocompact
// opt-outs, same as `ContextManager::autocompact_enabled`.
mw.autocompact_enabled = ctx.enabled && ctx.autocompact_enabled;
tracing::debug!(
tokenjuice_compaction_enabled = mw.tokenjuice_compaction_enabled,
compression = ?mw.tokenjuice_compression,
tool_result_budget_bytes = mw.tool_result_budget_bytes,
microcompact_keep_recent = mw.microcompact_keep_recent,
autocompact_enabled = mw.autocompact_enabled,
"[subagent_runner:graph] built sub-agent context middleware from config (#4466)"
);
}
Err(err) => {
tracing::debug!(
error = %err,
"[subagent_runner:graph] config load failed building sub-agent context mw; using defaults + compression profile"
);
}
}
mw
}
fn map_tinyagents_subagent_error(err: anyhow::Error) -> SubagentRunError {
match err.downcast::<SubagentRunError>() {
Ok(run_err) => run_err,
@@ -481,11 +667,117 @@ fn persist_subagent_transcript(
}
}
/// Persist a **failed** sub-agent run (#4466): write whatever rounds the live
/// transcript-snapshot middleware captured before the harness error to
/// `session_raw` (so `learning/transcript_ingest` can still ingest a failed run,
/// not skip an absent file), mirror those rounds onto the worker thread, and
/// append a trailing failure marker so the record is self-describing. Usage is
/// zeroed — the harness reported no totals on the error path — and the iteration
/// count is the number of completed rounds recovered.
#[allow(clippy::too_many_arguments)]
fn persist_failed_run(
workspace_dir: &std::path::Path,
transcript_stem: &str,
agent_id: &str,
task_id: &str,
provider_label: &str,
model: &str,
recovered: &[ChatMessage],
context_window: u64,
dispatcher: &str,
worker_thread_id: Option<&str>,
error: &SubagentRunError,
) {
let marker = format!("[subagent run failed before completion: {error}]");
let mut history = recovered.to_vec();
history.push(ChatMessage::assistant(marker.clone()));
// A failed run has no usage totals; record zeros so the transcript is still a
// valid, ingestable `session_raw` record with the failure surfaced.
let usage = AggregatedUsage::default();
persist_subagent_transcript(
workspace_dir,
transcript_stem,
agent_id,
task_id,
provider_label,
model,
&history,
&usage,
context_window,
dispatcher,
recovered.len() as u32,
);
if let Some(thread_id) = worker_thread_id {
mirror_worker_thread_from_history(
workspace_dir,
thread_id,
agent_id,
task_id,
recovered,
Some(marker.as_str()),
);
}
}
/// Append a worker-thread [`StoredMessage`](crate::openhuman::memory_conversations::ConversationMessage)
/// with the restored legacy [`SubagentObserver`] metadata (#4466): `scope`,
/// `agent_id`, `task_id`, plus the per-message `iteration`, `final`, `mode`, and
/// (for assistant tool rounds / tool results) `tool_calls` / `tool_call_id` /
/// `tool_name`. The migrated path had reduced this to `{scope, agent_id,
/// task_id}` only, dropping the fields worker-thread consumers key on.
#[allow(clippy::too_many_arguments)]
fn append_worker_message(
workspace_dir: &std::path::Path,
thread_id: &str,
agent_id: &str,
task_id: &str,
content: String,
sender: &str,
metadata: serde_json::Value,
) {
use crate::openhuman::memory_conversations::{
append_message, ConversationMessage as StoredMessage,
};
let mut extra = serde_json::json!({
"scope": "worker_thread",
"agent_id": agent_id,
"task_id": task_id,
"mode": "typed",
});
if let (Some(base), Some(extra_fields)) = (extra.as_object_mut(), metadata.as_object()) {
for (k, v) in extra_fields {
base.insert(k.clone(), v.clone());
}
}
let message = StoredMessage {
id: format!("{sender}:{}", uuid::Uuid::new_v4()),
content,
message_type: "text".to_string(),
extra_metadata: extra,
sender: sender.to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
};
if let Err(err) = append_message(workspace_dir.to_path_buf(), thread_id, message) {
tracing::debug!(
agent_id,
thread_id,
error = %err,
"[subagent_runner:graph] failed to append worker-thread message"
);
}
}
/// Mirror a sub-agent turn's structured conversation to its worker thread,
/// matching the legacy [`SubagentObserver`]: assistant turns (intents + final)
/// become `agent` messages, tool results become `user` messages. `extra_final`,
/// when set, is appended as a trailing `agent` message (the cap checkpoint or
/// clarifying question, which isn't a plain assistant turn in the transcript).
///
/// Each message carries the restored legacy metadata (#4466): a per-round
/// `iteration` counter, `final` on the trailing message, `tool_calls` on an
/// assistant round, and `tool_call_id` / `tool_name` on each tool result.
fn mirror_worker_thread(
workspace_dir: &std::path::Path,
thread_id: &str,
@@ -494,48 +786,80 @@ fn mirror_worker_thread(
conversation: &[ConversationMessage],
extra_final: Option<&str>,
) {
use crate::openhuman::memory_conversations::{
append_message, ConversationMessage as StoredMessage,
};
use std::collections::HashMap;
let append = |content: String, sender: &str| {
let message = StoredMessage {
id: format!("{sender}:{}", uuid::Uuid::new_v4()),
content,
message_type: "text".to_string(),
extra_metadata: serde_json::json!({
"scope": "worker_thread",
"agent_id": agent_id,
"task_id": task_id,
}),
sender: sender.to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
};
if let Err(err) = append_message(workspace_dir.to_path_buf(), thread_id, message) {
tracing::debug!(
agent_id,
thread_id,
error = %err,
"[subagent_runner:graph] failed to append worker-thread message"
);
// call_id -> tool name, so each tool result records the tool it came from.
let mut names: HashMap<&str, &str> = HashMap::new();
for msg in conversation {
if let ConversationMessage::AssistantToolCalls { tool_calls, .. } = msg {
for call in tool_calls {
names.insert(call.id.as_str(), call.name.as_str());
}
}
};
}
let mut iteration: u64 = 0;
for msg in conversation {
match msg {
ConversationMessage::AssistantToolCalls { text, .. } => {
ConversationMessage::AssistantToolCalls {
text, tool_calls, ..
} => {
iteration += 1;
if let Some(t) = text.as_deref().filter(|t| !t.trim().is_empty()) {
append(t.to_string(), "agent");
let call_names: Vec<&str> =
tool_calls.iter().map(|c| c.name.as_str()).collect();
append_worker_message(
workspace_dir,
thread_id,
agent_id,
task_id,
t.to_string(),
"agent",
serde_json::json!({
"iteration": iteration,
"final": false,
"tool_calls": call_names,
}),
);
}
}
ConversationMessage::ToolResults(results) => {
for r in results {
append(r.content.clone(), "user");
let tool_name = names
.get(r.tool_call_id.as_str())
.copied()
.unwrap_or("tool");
append_worker_message(
workspace_dir,
thread_id,
agent_id,
task_id,
r.content.clone(),
"user",
serde_json::json!({
"iteration": iteration,
"final": false,
"tool_call_id": r.tool_call_id,
"tool_name": tool_name,
}),
);
}
}
ConversationMessage::Chat(c) if c.role == "assistant" => {
if !c.content.trim().is_empty() {
append(c.content.clone(), "agent");
iteration += 1;
append_worker_message(
workspace_dir,
thread_id,
agent_id,
task_id,
c.content.clone(),
"agent",
serde_json::json!({
"iteration": iteration,
"final": extra_final.is_none(),
}),
);
}
}
_ => {}
@@ -543,15 +867,85 @@ fn mirror_worker_thread(
}
if let Some(text) = extra_final.filter(|t| !t.trim().is_empty()) {
append(text.to_string(), "agent");
append_worker_message(
workspace_dir,
thread_id,
agent_id,
task_id,
text.to_string(),
"agent",
serde_json::json!({ "iteration": iteration + 1, "final": true }),
);
}
}
/// Worker-thread mirror from a flat [`ChatMessage`] history (the error-recovery
/// path, #4466): assistant messages become `agent` rows, tool messages become
/// `user` rows. Used when only the recovered snapshot (not the typed
/// `conversation`) is available. `failure_final`, when set, is appended as a
/// trailing `agent` failure marker.
fn mirror_worker_thread_from_history(
workspace_dir: &std::path::Path,
thread_id: &str,
agent_id: &str,
task_id: &str,
history: &[ChatMessage],
failure_final: Option<&str>,
) {
let mut iteration: u64 = 0;
for m in history {
match m.role.as_str() {
"assistant" if !m.content.trim().is_empty() => {
iteration += 1;
append_worker_message(
workspace_dir,
thread_id,
agent_id,
task_id,
m.content.clone(),
"agent",
serde_json::json!({ "iteration": iteration, "final": false }),
);
}
"tool" if !m.content.trim().is_empty() => {
append_worker_message(
workspace_dir,
thread_id,
agent_id,
task_id,
m.content.clone(),
"user",
serde_json::json!({ "iteration": iteration, "final": false }),
);
}
_ => {}
}
}
if let Some(text) = failure_final.filter(|t| !t.trim().is_empty()) {
append_worker_message(
workspace_dir,
thread_id,
agent_id,
task_id,
text.to_string(),
"agent",
serde_json::json!({ "iteration": iteration + 1, "final": true }),
);
}
}
/// Build the `tool → outcome` digest the cap-hit summary call summarizes, in the
/// legacy `- {name} [{ok|failed}]: {output}` format (engine `run_tool_digest`),
/// pairing each tool result back to its call by id. Tool success isn't carried
/// on the converted transcript, so results are reported optimistically as `ok`.
fn build_cap_digest(conversation: &[ConversationMessage]) -> String {
/// pairing each tool result back to its call by id. Per-tool success is derived
/// from the turn's captured [`ToolCallOutcome`]s (#4467, item 7) rather than
/// reported optimistically as `ok`: a result whose call has no captured outcome
/// — e.g. a hallucinated/unknown tool the crate recovered without running
/// `after_tool` — is marked `failed`, so the summary no longer tells the model
/// every call succeeded.
fn build_cap_digest(
conversation: &[ConversationMessage],
tool_outcomes: &[crate::openhuman::tinyagents::ToolCallOutcome],
) -> String {
use std::collections::HashMap;
use std::fmt::Write as _;
@@ -565,6 +959,12 @@ fn build_cap_digest(conversation: &[ConversationMessage]) -> String {
}
}
// call_id -> success, from the captured per-call outcomes.
let success_by_id: HashMap<&str, bool> = tool_outcomes
.iter()
.map(|o| (o.call_id.as_str(), o.success))
.collect();
let mut out = String::new();
for msg in conversation {
if let ConversationMessage::ToolResults(results) = msg {
@@ -573,8 +973,15 @@ fn build_cap_digest(conversation: &[ConversationMessage]) -> String {
.get(r.tool_call_id.as_str())
.copied()
.unwrap_or("tool");
// Missing outcome → `false` (unknown/hallucinated tool): honest
// failed status rather than an optimistic `[ok]`.
let ok = success_by_id
.get(r.tool_call_id.as_str())
.copied()
.unwrap_or(false);
let tag = if ok { "ok" } else { "failed" };
let body = crate::openhuman::util::truncate_with_ellipsis(&r.content, 800);
let _ = writeln!(out, "- {name} [ok]: {body}");
let _ = writeln!(out, "- {name} [{tag}]: {body}");
}
}
}
@@ -660,7 +1067,7 @@ mod tests {
allowed.insert("echo".to_string());
let mut history = vec![ChatMessage::user("please echo hi")];
let (output, iterations, usage, early_exit, hit_cap) = run_subagent_via_graph(
let (output, iterations, usage, early_exit, hit_cap, _breaker) = run_subagent_via_graph(
provider,
"mock-model",
0.0,
@@ -683,6 +1090,7 @@ mod tests {
"root-session__real_tools",
"mock-channel",
None,
AgentTokenjuiceCompression::Off,
)
.await
.expect("graph subagent runs");
@@ -748,7 +1156,7 @@ mod tests {
let parent_tools: Arc<Vec<Box<dyn Tool>>> = Arc::new(vec![]);
let mut history = vec![ChatMessage::user("hi")];
let (output, _iters, _usage, _early, _hit_cap) = run_subagent_via_graph(
let (output, _iters, _usage, _early, _hit_cap, _breaker) = run_subagent_via_graph(
Arc::new(ThinkingStreamProvider),
"mock-model",
0.0,
@@ -771,6 +1179,7 @@ mod tests {
"root-session__scoped_deltas",
"mock-channel",
None,
AgentTokenjuiceCompression::Off,
)
.await
.expect("child-delta subagent runs");
@@ -894,7 +1303,7 @@ mod tests {
allowed.insert("ask_user_clarification".to_string());
let mut history = vec![ChatMessage::user("help me")];
let (output, iterations, _usage, early_exit, _hit_cap) = run_subagent_via_graph(
let (output, iterations, _usage, early_exit, _hit_cap, _breaker) = run_subagent_via_graph(
provider.clone(),
"mock-model",
0.0,
@@ -917,6 +1326,7 @@ mod tests {
"root-session__clarification",
"mock-channel",
None,
AgentTokenjuiceCompression::Off,
)
.await
.expect("ask-clarification subagent runs");
@@ -1000,7 +1410,7 @@ mod tests {
allowed.insert("noop".to_string());
let mut history = vec![ChatMessage::user("do a big task")];
let (output, iterations, _usage, early_exit, hit_cap) = run_subagent_via_graph(
let (output, iterations, _usage, early_exit, hit_cap, _breaker) = run_subagent_via_graph(
Arc::new(LoopForeverProvider),
"mock-model",
0.0,
@@ -1023,6 +1433,7 @@ mod tests {
"root-session__cap_hit",
"mock-channel",
None,
AgentTokenjuiceCompression::Off,
)
.await
.expect("cap-hit subagent runs");
@@ -20,6 +20,7 @@ use crate::openhuman::agent::harness::fork_context::{
};
use crate::openhuman::agent::harness::subagent_runner::extract_tool::ExtractFromResultTool;
use crate::openhuman::agent::harness::subagent_runner::handoff::ResultHandoffCache;
use crate::openhuman::agent::harness::subagent_runner::subagent_iter_cap_with_autonomous_lift;
use crate::openhuman::agent::harness::subagent_runner::tool_prep::{
build_text_mode_tool_instructions, filter_tool_indices, is_subagent_spawn_tool,
load_prompt_source, top_k_for_toolkit,
@@ -704,7 +705,7 @@ async fn run_typed_mode(
agent_id = %definition.id,
model = %model,
tool_count = allowed_names.len(),
max_iterations = definition.effective_max_iterations(),
max_iterations = subagent_iter_cap_with_autonomous_lift(definition.effective_max_iterations()),
iteration_policy = ?definition.iteration_policy,
"[subagent_runner:typed] resolved configuration"
);
@@ -958,87 +959,95 @@ async fn run_typed_mode(
);
}
let (output, iterations, agg_usage, early_exit_tool, hit_cap) = match &definition.graph {
AgentGraph::Default => {
super::graph::run_subagent_via_graph(
subagent_provider.clone(),
&model,
temperature,
&mut history,
parent.all_tools.clone(),
dynamic_tools,
filtered_specs.clone(),
allowed_names,
definition.effective_max_iterations(),
options.run_queue.clone(),
parent.on_progress.clone(),
&definition.id,
task_id,
definition.iteration_policy == IterationPolicy::Extended,
options.worker_thread_id.clone(),
parent.workspace_dir.clone(),
workspace_descriptor.clone(),
max_output_tokens,
model_vision,
&transcript_stem,
// Sub-agent turns record their provider label as the literal
// "subagent" (parity with the legacy observer's TurnObserver
// provenance), distinguishing delegated spend from the parent's
// own channel in per-thread usage reads.
"subagent",
// Progressive-disclosure handoff cache (shared with the
// extract_from_result tool registered above).
handoff_cache.clone(),
)
.await?
}
AgentGraph::Custom(run) => {
let req = AgentTurnRequest {
provider: subagent_provider.clone(),
model: model.clone(),
temperature,
history: std::mem::take(&mut history),
parent_tools: parent.all_tools.clone(),
dynamic_tools,
specs: filtered_specs.clone(),
allowed_names,
max_iterations: definition.effective_max_iterations(),
run_queue: options.run_queue.clone(),
on_progress: parent.on_progress.clone(),
agent_id: definition.id.clone(),
task_id: task_id.to_string(),
extended_policy: definition.iteration_policy == IterationPolicy::Extended,
worker_thread_id: options.worker_thread_id.clone(),
workspace_dir: parent.workspace_dir.clone(),
workspace_descriptor: workspace_descriptor.clone(),
max_output_tokens,
model_vision,
transcript_stem: transcript_stem.clone(),
provider_label: "subagent".to_string(),
handoff_cache: handoff_cache.clone(),
};
let res = run(req).await?;
history = res.history;
let AgentTurnUsage {
input_tokens,
output_tokens,
cached_input_tokens,
charged_amount_usd,
} = res.usage;
(
res.output,
res.iterations,
AggregatedUsage {
let (output, iterations, agg_usage, early_exit_tool, hit_cap, breaker_halt) =
match &definition.graph {
AgentGraph::Default => {
super::graph::run_subagent_via_graph(
subagent_provider.clone(),
&model,
temperature,
&mut history,
parent.all_tools.clone(),
dynamic_tools,
filtered_specs.clone(),
allowed_names,
subagent_iter_cap_with_autonomous_lift(definition.effective_max_iterations()),
options.run_queue.clone(),
parent.on_progress.clone(),
&definition.id,
task_id,
definition.iteration_policy == IterationPolicy::Extended,
options.worker_thread_id.clone(),
parent.workspace_dir.clone(),
workspace_descriptor.clone(),
max_output_tokens,
model_vision,
&transcript_stem,
// Sub-agent turns record their provider label as the literal
// "subagent" (parity with the legacy observer's TurnObserver
// provenance), distinguishing delegated spend from the parent's
// own channel in per-thread usage reads.
"subagent",
// Progressive-disclosure handoff cache (shared with the
// extract_from_result tool registered above).
handoff_cache.clone(),
// Agent-level TokenJuice profile → sub-agent context middleware
// (#4466), so sub-agent tool outputs compact like the chat path.
definition.effective_tokenjuice_compression(),
)
.await?
}
AgentGraph::Custom(run) => {
let req = AgentTurnRequest {
provider: subagent_provider.clone(),
model: model.clone(),
temperature,
history: std::mem::take(&mut history),
parent_tools: parent.all_tools.clone(),
dynamic_tools,
specs: filtered_specs.clone(),
allowed_names,
max_iterations: subagent_iter_cap_with_autonomous_lift(
definition.effective_max_iterations(),
),
run_queue: options.run_queue.clone(),
on_progress: parent.on_progress.clone(),
agent_id: definition.id.clone(),
task_id: task_id.to_string(),
extended_policy: definition.iteration_policy == IterationPolicy::Extended,
worker_thread_id: options.worker_thread_id.clone(),
workspace_dir: parent.workspace_dir.clone(),
workspace_descriptor: workspace_descriptor.clone(),
max_output_tokens,
model_vision,
transcript_stem: transcript_stem.clone(),
provider_label: "subagent".to_string(),
handoff_cache: handoff_cache.clone(),
tokenjuice_compression: definition.effective_tokenjuice_compression(),
};
let res = run(req).await?;
history = res.history;
let AgentTurnUsage {
input_tokens,
output_tokens,
cached_input_tokens,
charged_amount_usd,
},
res.early_exit_tool,
res.hit_cap,
)
}
};
} = res.usage;
(
res.output,
res.iterations,
AggregatedUsage {
input_tokens,
output_tokens,
cached_input_tokens,
charged_amount_usd,
},
res.early_exit_tool,
res.hit_cap,
res.breaker_halt,
)
}
};
// Determine status: if the turn engine exited early because of
// ask_user_clarification, checkpoint the history and return
@@ -1104,6 +1113,22 @@ async fn run_typed_mode(
question,
options: options_vec,
}
} else if let Some(reason) = breaker_halt {
// The repeated-failure / repeat-progress circuit breaker halted the run
// (#4466). It is NOT a clean finish: `output` carries the breaker's
// root-cause summary, not a completed answer. Surface `Incomplete` with
// the halt reason so a delegating parent relays the blocker instead of
// treating the halted child as finished (the migrated path reported
// `hit_cap=false` → `Completed`, hiding the halt).
tracing::warn!(
task_id = %task_id,
agent_id = %definition.id,
reason = %reason,
"[subagent_runner] child halted by circuit breaker; reporting Incomplete (#4466)"
);
crate::openhuman::agent::harness::subagent_runner::types::SubagentRunStatus::Incomplete {
reason,
}
} else if hit_cap {
// The tinyagents run stopped at the model-call cap with work still
// pending (graph summarized a resumable checkpoint into `output`).
@@ -1206,6 +1231,31 @@ impl crate::openhuman::inference::provider::Provider for TextModeProvider {
self.inner.effective_context_window(model).await
}
// #4469 item 2: forward the local-provider identity + cache passthroughs. This
// decorator only masks native tool calling (above); everything about *where*
// and *how* the inner provider runs must pass through unchanged. Without these
// the default trait impls report the inner as a remote, non-caching provider,
// so a local runtime behind text mode loses its `n_keep >= n_ctx` un-evictable
// prefix guard (`is_local_provider*` / `loaded_context_window`, #3550) and its
// KV-cache pricing/strategy (`prompt_cache_capabilities`, #3939).
fn is_local_provider(&self) -> bool {
self.inner.is_local_provider()
}
fn is_local_provider_for_model(&self, model: &str) -> bool {
self.inner.is_local_provider_for_model(model)
}
async fn loaded_context_window(&self, model: &str) -> Option<u64> {
self.inner.loaded_context_window(model).await
}
fn prompt_cache_capabilities(
&self,
) -> crate::openhuman::inference::provider::traits::PromptCacheCapabilities {
self.inner.prompt_cache_capabilities()
}
async fn warmup(&self) -> anyhow::Result<()> {
self.inner.warmup().await
}
@@ -17,6 +17,17 @@ use tinyagents::harness::store::Store;
const ARTIFACT_ROOT: &str = "artifacts/tool-results";
const AGGREGATE_PREVIEW_BUDGET_BYTES: usize = 512;
/// #4469 item 6: floor for how tightly a persisted `[tool_result_preview]`
/// envelope may be bounded during aggregate spill. `allowed_len` can saturate to
/// `0` (or a handful of bytes) once earlier-spilled results have already consumed
/// the aggregate budget; bounding the envelope to that would return `""` — or a
/// header cut mid-line — discarding the `artifact_path` pointer the model needs
/// to `file_read` the full output. This floor keeps the envelope header (through
/// the `artifact_path` / `read_with` lines) intact even when the raw budget math
/// says zero; `apply_tool_result_budget` retains the head, so the pointer always
/// survives. Slightly overshooting the aggregate budget here is the correct
/// trade — a valid pointer is worth a few hundred bytes.
const MIN_ENVELOPE_ALLOWANCE_BYTES: usize = 512;
pub(crate) const TINYAGENTS_TOOL_RESULT_ARTIFACT_STORE: &str = "openhuman_tool_result_artifacts";
const TRAILER_RESERVED: usize = 256;
@@ -264,8 +275,13 @@ pub(crate) async fn apply_per_result_persistence(
Ok(persisted) => {
let (output, final_bytes) = bound_text_to_budget(persisted.output, budget_bytes);
if final_bytes >= original_bytes {
// #4469 item 9: this branch does NOT fall back to inline
// truncation — the envelope is returned regardless, because it
// carries the `artifact_path` pointer to the full stored output
// (worth keeping even when the preview text nets no byte saving
// vs. the raw result). Log it as an observation only.
log::debug!(
"[agent][tool-result-artifacts] persisted envelope too large tool={} original_bytes={} final_bytes={} budget_bytes={} -- falling back to inline truncation",
"[agent][tool-result-artifacts] persisted envelope not smaller than raw result tool={} original_bytes={} final_bytes={} budget_bytes={} -- keeping envelope for its artifact_path pointer",
tool_name,
original_bytes,
final_bytes,
@@ -362,7 +378,14 @@ pub(crate) async fn spill_aggregate_tool_results(
};
match persisted_output {
Ok(persisted) => {
let (output, final_bytes) = bound_text_to_budget(persisted.output, allowed_len);
// #4469 item 6: never bound the preview envelope below the minimum
// that preserves its `[tool_result_preview]` header + artifact
// pointer — `allowed_len` can be 0 here, which would blank the
// result and strip the `artifact_path` the model reads to recover
// the full output.
let envelope_allowance = allowed_len.max(MIN_ENVELOPE_ALLOWANCE_BYTES);
let (output, final_bytes) =
bound_text_to_budget(persisted.output, envelope_allowance);
total = total
.saturating_sub(original_len)
.saturating_add(final_bytes);
@@ -579,7 +602,21 @@ mod tests {
spill_aggregate_tool_results(&mut results, Some(&store), 500).await;
let total: usize = results.iter().map(|result| result.output.len()).sum();
assert!(total <= 500, "total={total}");
// #4469 item 6: the aggregate spill now floors each persisted envelope at
// MIN_ENVELOPE_ALLOWANCE_BYTES so the `[tool_result_preview]` header +
// `artifact_path` pointer always survives (previously an exhausted budget
// could blank a result to ""). That is a documented trade — the total may
// slightly overshoot the raw aggregate budget — so the invariant is now:
// (a) no envelope is blanked, and (b) the total stays bounded by the
// per-result floor rather than the raw budget.
assert!(
results.iter().all(|result| !result.output.is_empty()),
"no persisted envelope may be blanked — the artifact pointer must survive"
);
assert!(
total <= results.len() * MIN_ENVELOPE_ALLOWANCE_BYTES,
"total={total} exceeds the per-result envelope floor bound"
);
assert!(tmp
.path()
.join("artifacts/tool-results/session/one/one.txt")
@@ -12,7 +12,7 @@
//! them to the sub-agent prompt so the (vision-capable) sub-agent's turn
//! rehydrates the image from the on-disk sidecar.
//!
//! Mirrors [`super::model_vision_context`]. Scoped around the orchestrator's
//! A task-local carrier scoped around the orchestrator's
//! turn future (`run_turn_via_tinyagents_shared`);
//! [`current_turn_image_placeholders`] returns an empty vec when no scope is
//! active (CLI / direct invocation / tests) — strictly additive.
+5
View File
@@ -190,6 +190,11 @@ pub enum AgentProgress {
elapsed_ms: u64,
/// 1-based child iteration index.
iteration: u32,
/// Present when `success` is false: a user-facing classification of the
/// child tool failure, mirroring [`Self::ToolCallCompleted::failure`] so
/// a failed sub-agent row carries the same "why + what to do next" copy
/// instead of discarding the already-computed classification (#4459).
failure: Option<crate::openhuman::tool_status::ClassifiedFailure>,
},
/// A chunk of a sub-agent's visible assistant text arrived from the
+76 -13
View File
@@ -25,14 +25,22 @@
//!
//! Spans always carry *metadata* — span names, counts, timings, and
//! token/cost figures. While `observability.agent_tracing.capture_content` is
//! on (its default), the turn's prompt/reply and **truncated** tool
//! arguments/results are additionally recorded as span `input`/`output`;
//! with the flag off, none of that content ever reaches the in-memory span.
//! on, the turn's prompt/reply and **truncated** tool arguments/results are
//! additionally recorded as span `input`/`output`; with the flag off (the
//! default — #4454), none of that content ever reaches the in-memory span, so
//! no exporter (NDJSON file, app log, or Langfuse) can leak it.
//! Streamed text/thinking deltas (`TextDelta`, `ThinkingDelta`,
//! `ToolCallArgsDelta`), raw error strings, and filesystem paths are **never**
//! recorded regardless of the flag, honoring the project's "never log secrets
//! or full PII" rule for logs.
//!
//! The one exception is the turn's prompt/reply, delivered via
//! `AgentProgress::TurnContent`. It is attached to the turn span **only** when
//! the operator opts in via `observability.agent_tracing.capture_content`
//! (default `false`). That gate is enforced at storage time in
//! [`SpanCollector`] — the single choke point — so with the default off, no
//! exporter (NDJSON file, app log, or Langfuse push) can ever serialize it.
//!
//! ## Wiring
//!
//! [`SpanCollector`] is a pure state machine: feed it the progress events plus
@@ -226,7 +234,16 @@ pub enum SpanStatus {
}
/// A single finished (or in-flight) span. Field names follow OpenTelemetry
/// conventions so the NDJSON drops cleanly into an OTel/Langfuse importer.
/// conventions (snake_case `trace_id`/`span_id`/`start_unix_ms`/…) so the raw
/// NDJSON file/log export is a self-describing OTel-style span dump for local
/// inspection.
///
/// #4469 item 13: this raw record is **not** directly Langfuse-ingestible — the
/// Langfuse `/api/public/ingestion` API needs each span wrapped in a
/// `{ type, id, timestamp, body }` event envelope. That envelope is produced
/// only by [`langfuse::spans_to_langfuse_batch`] on the remote-push path; the
/// local NDJSON exporter intentionally emits the raw spans, not the batch
/// format.
#[derive(Debug, Clone, Serialize)]
pub struct TraceSpan {
/// Trace id (the session id) — shared by every span in the run.
@@ -249,12 +266,13 @@ pub struct TraceSpan {
pub status: SpanStatus,
/// Metadata-only attributes (no secrets/PII).
pub attributes: BTreeMap<String, serde_json::Value>,
/// Optional prompt/input content. Populated only when content capture is on
/// (via `AgentProgress::TurnContent`); the exporter still gates transmission
/// behind `observability.agent_tracing.capture_content`.
/// Optional prompt/input content. Populated (via `AgentProgress::TurnContent`)
/// **only** when `observability.agent_tracing.capture_content` is opted in —
/// the [`SpanCollector`] drops content at storage time otherwise, so with the
/// default gate off this is always `None` and no exporter can serialize it.
#[serde(skip_serializing_if = "Option::is_none")]
pub input: Option<serde_json::Value>,
/// Optional model-reply/output content. Same capture/gating rules as
/// Optional model-reply/output content. Same storage-level gating as
/// [`Self::input`].
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<serde_json::Value>,
@@ -297,6 +315,15 @@ pub struct SpanCollector {
/// fresh nonce makes every span id globally unique.
id_prefix: String,
/// Storage-level privacy gate (mirrors
/// `observability.agent_tracing.capture_content`). When `false` (the
/// default), prompt/reply content from [`AgentProgress::TurnContent`] is
/// **never attached to a span** — so no exporter (NDJSON file, app log, or
/// Langfuse push) can ever serialize it. This is the single choke point
/// referenced in the module docs: gating at storage protects every present
/// and future exporter, not just the transmission path.
capture_content: bool,
turn_span_id: Option<String>,
turn_span_index: Option<usize>,
current_iteration_span_id: Option<String>,
@@ -315,6 +342,8 @@ impl SpanCollector {
spans: Vec::new(),
next_span_seq: 0,
id_prefix: uuid::Uuid::new_v4().simple().to_string(),
// Metadata-only by default; opt in via `with_content_capture`.
capture_content: false,
turn_span_id: None,
turn_span_index: None,
current_iteration_span_id: None,
@@ -324,6 +353,15 @@ impl SpanCollector {
}
}
/// Opt into attaching prompt/reply content to spans (from
/// [`AgentProgress::TurnContent`]). Wire this to
/// `observability.agent_tracing.capture_content`. Left off, content is
/// dropped at storage time so it can never reach any exporter.
pub fn with_content_capture(mut self, capture_content: bool) -> Self {
self.capture_content = capture_content;
self
}
/// All spans recorded so far (finished and in-flight).
pub fn spans(&self) -> &[TraceSpan] {
&self.spans
@@ -990,8 +1028,19 @@ impl SpanCollector {
}
AgentProgress::TurnContent { input, output } => {
// Attach prompt/reply to the root turn span. Held in-memory only;
// the exporter decides whether to transmit it (opt-in gate).
// Storage-level privacy gate (#4454): prompt/reply text is
// attached to the span ONLY when content capture is opted in.
// With the gate off (default), the content is dropped here so no
// exporter — NDJSON file, app log, or Langfuse push — can ever
// serialize it. This is the single choke point; the exporters
// deliberately do not re-check the flag.
if !self.capture_content {
log::debug!(
target: "agent-tracing",
"[agent-tracing] TurnContent dropped at storage (capture_content=false)"
);
return;
}
let index = match self.turn_span_index {
Some(idx) => idx,
None => {
@@ -1006,6 +1055,10 @@ impl SpanCollector {
if let Some(text) = output {
span.output = Some(serde_json::Value::String(text.clone()));
}
log::debug!(
target: "agent-tracing",
"[agent-tracing] TurnContent attached to turn span (capture_content=true)"
);
}
}
@@ -1161,12 +1214,22 @@ pub(crate) fn export_spans(config: &AgentTracingConfig, spans: &[TraceSpan]) {
}
}
None => {
// No path configured — surface to the log so the export still works
// on read-only / sandboxed deployments.
// No path configured. Surface only metadata (count + trace id) at
// `info` so the export is visible on read-only / sandboxed
// deployments WITHOUT ever printing span content at `info` (#4454).
// The NDJSON body — which may carry prompt/reply text when
// `capture_content` is opted in — goes to `debug` only. With the
// default gate off, the storage layer already strips content, so
// `payload` is metadata-only regardless.
log::info!(
"[agent-tracing] {} spans (trace_id={}):\n{}",
"[agent-tracing] {} spans (trace_id={}) — set observability.agent_tracing.export_path to persist",
spans.len(),
spans.first().map(|s| s.trace_id.as_str()).unwrap_or(""),
);
log::debug!(
target: "agent-tracing",
"[agent-tracing] span NDJSON ({} spans):\n{}",
spans.len(),
payload.trim_end()
);
}
@@ -259,16 +259,22 @@ fn apply_usage_fields(body: &mut Value, span: &TraceSpan) -> bool {
}
let input = input.unwrap_or(0);
let output = output.unwrap_or(0);
let mut usage = Map::new();
usage.insert("input".to_string(), json!(input));
usage.insert("output".to_string(), json!(output));
usage.insert("total".to_string(), json!(input.saturating_add(output)));
// Cache reads always flow into usageDetails (0 included) so the figure is
// explicit rather than absent when no cache was hit.
let cached = attrs
.get("gen_ai.usage.cached_input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
// #4454: `input_tokens` is INCLUSIVE of cached prompt tokens (cost.rs treats
// cached as a subset of input). Langfuse sums `usageDetails` components as
// disjoint buckets, so emit the NON-cached input (input - cached) — the
// components (non_cached_input + cache_read + output) are then disjoint and
// reconcile to `total` = input_tokens + output_tokens.
let non_cached_input = input.saturating_sub(cached);
let mut usage = Map::new();
usage.insert("input".to_string(), json!(non_cached_input));
usage.insert("output".to_string(), json!(output));
usage.insert("total".to_string(), json!(input.saturating_add(output)));
// Cache reads always flow into usageDetails (0 included) so the figure is
// explicit rather than absent when no cache was hit.
usage.insert("cache_read_input_tokens".to_string(), json!(cached));
// Reasoning + cache-write tokens ride along whenever the span carries them
// (the collector stamps them when > 0). Langfuse accepts arbitrary
+15 -12
View File
@@ -288,6 +288,7 @@ fn subagent_lifecycle_nests_under_the_turn() {
output: "file contents".to_string(),
elapsed_ms: 40,
iteration: 1,
failure: None,
},
30,
),
@@ -663,22 +664,22 @@ async fn export_run_trace_otel_backend_uses_local_sink() {
// ── Route A: content + grouping + span-id uniqueness ────────────────────────
#[test]
fn turn_content_attaches_input_output_to_turn_span() {
let c = collect(&[
(AgentProgress::TurnStarted, 1_000),
(
AgentProgress::TurnContent {
input: Some("what is your favorite color?".to_string()),
output: Some("i'm partial to teal".to_string()),
},
1_100,
),
]);
fn turn_content_attaches_input_output_to_turn_span_when_capture_enabled() {
// Content lands on the span ONLY when capture is opted in (#4454).
let mut c = SpanCollector::new(ctx()).with_content_capture(true);
c.record(&AgentProgress::TurnStarted, 1_000);
c.record(
&AgentProgress::TurnContent {
input: Some("what is your favorite color?".to_string()),
output: Some("i'm partial to teal".to_string()),
},
1_100,
);
let turn = find(c.spans(), "agent.turn");
assert_eq!(
turn.input.as_ref().and_then(|v| v.as_str()),
Some("what is your favorite color?"),
"TurnContent input must land on the turn span"
"TurnContent input must land on the turn span when capture_content=true"
);
assert_eq!(
turn.output.as_ref().and_then(|v| v.as_str()),
@@ -791,6 +792,7 @@ fn tool_io_is_captured_when_capture_content_is_on() {
output: "file contents".to_string(),
elapsed_ms: 4,
iteration: 1,
failure: None,
},
4,
);
@@ -838,6 +840,7 @@ fn tool_io_is_never_recorded_when_capture_content_is_off() {
output: "sekrit".to_string(),
elapsed_ms: 4,
iteration: 1,
failure: None,
},
4,
),
@@ -184,6 +184,10 @@ pub(crate) fn spawn_progress_bridge(
.source
.clone()
.unwrap_or_else(|| "chat".to_string());
// Storage-level privacy gate (#4454): capture_content (off by
// default) rides on the TraceContext so the collector only attaches
// prompt/reply content to spans when the operator opted in — no
// exporter can serialize prompt/reply text otherwise.
let capture_content = config.observability.agent_tracing.capture_content;
log::debug!(
"[web_channel][bridge] trace context trace_id={} user_attributed={} \
@@ -896,7 +900,12 @@ pub(crate) fn spawn_progress_bridge(
output,
elapsed_ms,
iteration,
failure,
} => {
// Serialize the classified failure (if any) so a failed
// sub-agent tool row carries its "why + next" copy on the
// wire + ledger, matching the main-agent path (#4459).
let failure_json = failure.as_ref().and_then(|f| serde_json::to_value(f).ok());
ledger_append_event(
&config,
RunEventAppend {
@@ -909,7 +918,8 @@ pub(crate) fn spawn_progress_bridge(
"success": success,
"outputChars": output_chars,
"elapsedMs": elapsed_ms,
"iteration": iteration
"iteration": iteration,
"failure": failure_json,
}),
},
);
@@ -928,6 +938,7 @@ pub(crate) fn spawn_progress_bridge(
// bounded size for the wire (#4007); `output_chars` +
// `elapsed_ms` still ride along in `subagent` below.
output: Some(cap_wire_output(output)),
failure: failure_json,
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
agent_id: Some(agent_id),
+2 -2
View File
@@ -48,6 +48,6 @@ pub use error_code::{
pub use factory::{create_chat_provider, provider_for_role, BYOK_INCOMPLETE_SENTINEL};
pub use ops::*;
pub use resolved_route::{
current_resolved_provider_route, record_resolved_provider_route,
with_resolved_provider_route_scope, ResolvedProviderRoute,
current_resolved_provider_route, current_route_slot, record_resolved_provider_route,
with_resolved_provider_route_scope, with_route_slot, ResolvedProviderRoute, RouteSlot,
};
@@ -13,7 +13,12 @@ pub struct ResolvedProviderRoute {
pub model: String,
}
type RouteSlot = Arc<Mutex<Option<ResolvedProviderRoute>>>;
/// The ambient audit slot shared by an enclosing
/// [`with_resolved_provider_route_scope`]. Exposed so a detached provider call
/// (issue #4460: streamed calls run in a `tokio::spawn`) can capture the caller's
/// slot before crossing the spawn boundary and re-establish it inside, so its
/// `record_resolved_provider_route` writes still land in the caller's scope.
pub type RouteSlot = Arc<Mutex<Option<ResolvedProviderRoute>>>;
tokio::task_local! {
static RESOLVED_PROVIDER_ROUTE: RouteSlot;
@@ -51,6 +56,28 @@ pub fn record_resolved_provider_route(provider: impl Into<String>, model: impl I
);
}
/// Clone the ambient audit slot handle (the `Arc`, not the recorded route) set
/// by an enclosing [`with_resolved_provider_route_scope`]. `None` outside a
/// scope. Capture this **before** a `tokio::spawn` so the detached task can
/// re-establish the same slot via [`with_route_slot`] and have its route writes
/// reach the caller's scope — task-locals do not propagate across `spawn`
/// (issue #4460).
pub fn current_route_slot() -> Option<RouteSlot> {
RESOLVED_PROVIDER_ROUTE.try_with(|slot| slot.clone()).ok()
}
/// Re-establish a [`current_route_slot`]-captured `slot` as the ambient
/// resolved-route task-local for `future`. Any `record_resolved_provider_route`
/// call inside `future` then writes back to the original caller's audit slot,
/// even when `future` runs in a detached `tokio::spawn` (issue #4460).
pub async fn with_route_slot<F>(slot: RouteSlot, future: F) -> F::Output
where
F: std::future::Future,
{
tracing::trace!("[provider] resolved-route slot re-established across spawn boundary");
RESOLVED_PROVIDER_ROUTE.scope(slot, Box::pin(future)).await
}
pub fn current_resolved_provider_route() -> Option<ResolvedProviderRoute> {
let route = RESOLVED_PROVIDER_ROUTE
.try_with(|slot| slot.lock().unwrap_or_else(|e| e.into_inner()).clone())
+9 -3
View File
@@ -11,9 +11,15 @@
//! - [`account_turn_against_goal`] folds a completed turn's token + time usage
//! into the active goal, flipping it to `budget_limited` when the cap is
//! crossed.
//! - [`GoalBudgetStopHook`] hard-stops an in-flight turn the moment an *active*
//! goal's running usage would exceed its budget, so an autonomous run can't
//! blow past the ceiling between accounting points.
//! - [`GoalBudgetStopHook`] votes to stop an in-flight turn as soon as an
//! *active* goal's running usage would exceed its budget. #4469 item 1: the
//! stop is a graceful *pause*, not an instantaneous abort — the vote fires in
//! the stop-hook middleware's `after_model`, and the harness drains the pause
//! at the **top of the next iteration**, so the tool round for the model call
//! that tripped the budget still runs and the turn's wrap-up summary may spend
//! one more model call before the partial transcript is returned. It bounds
//! an autonomous run to a small, deterministic overshoot past the ceiling
//! rather than a hard cut at the exact accounting point.
use std::path::{Path, PathBuf};
+19 -3
View File
@@ -18,8 +18,8 @@ use crate::openhuman::agent::progress::AgentProgress;
use super::store::TurnStateStore;
use super::types::{
SubagentActivity, SubagentToolCall, SubagentTranscriptItem, ToolTimelineEntry,
ToolTimelineStatus, TranscriptItem, TurnLifecycle, TurnPhase, TurnState,
PersistedToolFailure, SubagentActivity, SubagentToolCall, SubagentTranscriptItem,
ToolTimelineEntry, ToolTimelineStatus, TranscriptItem, TurnLifecycle, TurnPhase, TurnState,
};
const MIRROR_LOG_PREFIX: &str = "[threads:turn_state:mirror]";
@@ -130,13 +130,17 @@ impl TurnStateMirror {
detail: display_detail.clone(),
source_tool_name: None,
subagent: None,
failure: None,
});
}
self.flush();
true
}
AgentProgress::ToolCallCompleted {
call_id, success, ..
call_id,
success,
failure,
..
} => {
if let Some(entry) = self
.state
@@ -150,6 +154,10 @@ impl TurnStateMirror {
} else {
ToolTimelineStatus::Error
};
// Persist the plain-language failure so the explanation
// survives a thread switch / cold boot (#4459). Clear it on
// a (re-)success so a retried row doesn't keep stale copy.
entry.failure = failure.as_ref().map(PersistedToolFailure::from);
}
if self.state.active_tool.is_some() {
self.state.active_tool = None;
@@ -193,6 +201,7 @@ impl TurnStateMirror {
tool_calls: Vec::new(),
transcript: Vec::new(),
}),
failure: None,
});
self.flush();
true
@@ -269,6 +278,7 @@ impl TurnStateMirror {
output_chars: None,
display_name: display_label.clone(),
detail: display_detail.clone(),
failure: None,
});
// Mirror the call into the ordered transcript so the
// rehydrated thoughts interleave it at the right spot.
@@ -296,6 +306,7 @@ impl TurnStateMirror {
success,
output_chars,
elapsed_ms,
failure,
..
} => {
if let Some(entry) = self.find_subagent_entry_mut(task_id) {
@@ -305,6 +316,7 @@ impl TurnStateMirror {
} else {
ToolTimelineStatus::Error
};
let persisted_failure = failure.as_ref().map(PersistedToolFailure::from);
if let Some(call) = activity
.tool_calls
.iter_mut()
@@ -314,6 +326,9 @@ impl TurnStateMirror {
call.status = status;
call.elapsed_ms = Some(*elapsed_ms);
call.output_chars = Some(*output_chars);
// Carry the child failure so a failed sub-agent row
// keeps its explanation across a round-trip (#4459).
call.failure = persisted_failure;
}
// Keep the transcript's Tool item in lockstep so the
// rehydrated row shows the terminal status + timing.
@@ -399,6 +414,7 @@ impl TurnStateMirror {
detail: None,
source_tool_name: None,
subagent: None,
failure: None,
});
}
false
@@ -411,6 +411,7 @@ fn subagent_transcript_persists_interleaved_prose_and_tools() {
output: String::new(),
elapsed_ms: 12,
iteration: 1,
failure: None,
});
let activity = m.snapshot().tool_timeline[0]
@@ -28,6 +28,7 @@ fn put_then_get_roundtrips_state() {
detail: None,
source_tool_name: None,
subagent: None,
failure: None,
});
store.put(&state).expect("put");
+55
View File
@@ -48,6 +48,50 @@ pub enum ToolTimelineStatus {
Error,
}
/// Persisted, plain-language explanation of a FAILED tool row (#4459).
///
/// Mirrors the live socket `failure` object and the frontend
/// `PersistedToolFailure` (`app/src/types/turnState.ts`) 1:1 — camelCase on the
/// wire, `class`/`category` as the taxonomy's stable variant names — so a
/// settled/reloaded turn keeps its "why + what to do next" copy across a thread
/// switch or a cold boot. Absent on successful rows and on snapshots written
/// before this field.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersistedToolFailure {
/// Stable failure-class variant name, e.g. `"Timeout"`, `"Denied"`.
pub class: String,
/// Stable category variant name, e.g. `"Recoverable"`, `"UserDeclined"`.
pub category: String,
/// Whether the core considers the failure automatically recoverable.
pub recoverable: bool,
/// Plain-language cause (`causePlain` on the wire).
pub cause_plain: String,
/// Plain-language next action (`nextAction` on the wire).
pub next_action: String,
}
impl From<&crate::openhuman::tool_status::ClassifiedFailure> for PersistedToolFailure {
fn from(f: &crate::openhuman::tool_status::ClassifiedFailure) -> Self {
// Serialize the enums to their wire variant name so the persisted
// `class`/`category` strings match exactly what the live socket emits
// (`ClassifiedFailure` serializes each as its bare variant name).
fn variant_name<T: Serialize>(v: &T) -> String {
serde_json::to_value(v)
.ok()
.and_then(|j| j.as_str().map(str::to_string))
.unwrap_or_default()
}
Self {
class: variant_name(&f.class),
category: variant_name(&f.category),
recoverable: f.recoverable,
cause_plain: f.cause_plain.clone(),
next_action: f.next_action.clone(),
}
}
}
/// One row in the per-turn tool timeline.
///
/// Field names use camelCase on the wire so a snapshot can be applied
@@ -70,6 +114,11 @@ pub struct ToolTimelineEntry {
pub source_tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent: Option<SubagentActivity>,
/// Plain-language failure explanation for a FAILED row, carried in the
/// snapshot so it survives a thread switch / cold boot (#4459). `None` on
/// success and on legacy snapshots.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<PersistedToolFailure>,
}
/// Live sub-agent activity nested under a `subagent:*` timeline row.
@@ -133,6 +182,12 @@ pub struct SubagentToolCall {
/// Server-computed contextual detail (e.g. the path / recipient).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
/// Plain-language failure explanation for a FAILED child call, so a
/// sub-agent's failed row carries the same "why + next" copy as a
/// main-agent row and it survives a snapshot round-trip (#4459). `None` on
/// success and on legacy snapshots.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<PersistedToolFailure>,
}
/// One ordered item in a sub-agent's processing transcript — its streamed
+41
View File
@@ -0,0 +1,41 @@
//! RAII guard that aborts a spawned task when it is dropped (issue #4460).
//!
//! `ProviderModel::stream` runs the provider call in a detached `tokio::spawn`
//! producer. Without a lifetime tie, a hard turn cancellation (`AbortHandle`)
//! drops the consumer stream but leaves the producer running to completion — the
//! provider call still finishes and is still billed. Holding the producer's
//! [`JoinHandle`] inside this guard, and moving the guard into the consumer
//! stream's state, ties the two lifetimes: dropping the stream (turn future
//! aborted/dropped) drops the guard, which aborts the in-flight provider call.
use tokio::task::JoinHandle;
/// Aborts the wrapped task on drop unless it has already finished.
pub(super) struct AbortOnDrop {
handle: JoinHandle<()>,
/// Grep-friendly label for the abort debug log (e.g. the model name).
label: String,
}
impl AbortOnDrop {
pub(super) fn new(handle: JoinHandle<()>, label: impl Into<String>) -> Self {
Self {
handle,
label: label.into(),
}
}
}
impl Drop for AbortOnDrop {
fn drop(&mut self) {
if self.handle.is_finished() {
// Producer already emitted its terminal item — nothing to abort.
return;
}
tracing::debug!(
label = %self.label,
"[tinyagents] aborting in-flight provider stream producer on drop (turn cancelled) — #4460"
);
self.handle.abort();
}
}
+41
View File
@@ -308,6 +308,13 @@ pub(super) fn messages_to_conversation(messages: &[Message]) -> Vec<Conversation
/// The suffix of `messages` produced *after* the most recent user turn — i.e.
/// the assistant/tool messages a single turn appended. Robust to front-trimming
/// middleware (which drops old messages but keeps the current user turn).
///
/// Retired from the persistence path in favour of [`messages_since_request`]
/// (issue #4455) because an injected mid-turn steer moves the last-user boundary
/// and truncates persisted history; kept only as a documented, test-covered
/// reference to the legacy convention. `allow(dead_code)` off the test build
/// since it now has no non-test caller.
#[cfg_attr(not(test), allow(dead_code))]
pub(super) fn messages_since_last_user(messages: &[Message]) -> &[Message] {
let start = messages
.iter()
@@ -317,6 +324,40 @@ pub(super) fn messages_since_last_user(messages: &[Message]) -> &[Message] {
&messages[start..]
}
/// The transcript suffix appended during a single turn, sliced at an **explicit
/// boundary** captured *before* the run — `base_len` is the length of the
/// request's `input` transcript (`history_to_messages(&history).len()`).
///
/// This replaces the fragile "suffix after the last `Message::User`" convention
/// ([`messages_since_last_user`]) on the persistence path. Mid-turn steer/collect
/// messages are injected as `Message::user(...)` (`forward_steers` /
/// `forward_collects`), which *moves* the last-user boundary — so slicing on it
/// silently dropped every pre-steer assistant/tool round **and** the steer text
/// itself from persisted history, the next-turn KV-cache prefix, and subagent
/// checkpoints (issue #4455). Anchoring on the pre-run request length instead
/// captures the full post-request transcript, injected steers included, in
/// execution order.
///
/// The crate returns the full transcript in `run.messages`: the agent loop seeds
/// its working transcript from `input` (`messages = input`) and only ever
/// *appends* (assistant/tool rounds + applied steers); the compression/trim
/// middleware rewrites the per-call `request.messages.clone()`, never the loop's
/// working transcript. So `messages` always starts with the `base_len` request
/// messages as a prefix. `base_len` is clamped defensively in case a future
/// crate change ever front-trims the persisted transcript.
pub(super) fn messages_since_request(messages: &[Message], base_len: usize) -> &[Message] {
let start = base_len.min(messages.len());
if start != base_len {
tracing::warn!(
base_len,
transcript_len = messages.len(),
"[tinyagents] messages_since_request boundary exceeds transcript length; \
clamping (transcript may have been front-trimmed) persisting full transcript"
);
}
&messages[start..]
}
/// Convert a harness transcript into openhuman [`ChatMessage`]s for a provider
/// that does **not** support native tool calls (text/prompt-guided mode).
///
File diff suppressed because it is too large Load Diff
+395 -133
View File
@@ -19,6 +19,7 @@
//! `ask_user_clarification` early-exit pause are all re-wired onto the
//! tinyagents harness.
mod abort_guard;
mod convert;
pub(crate) mod delegation;
mod embeddings;
@@ -33,6 +34,7 @@ pub(crate) mod replay;
pub(crate) mod retriever;
mod routes;
mod run_cancellation_context;
mod steering_forwarder;
pub(crate) mod stop_hooks;
pub(crate) mod subagent_graph;
mod summarize;
@@ -45,16 +47,14 @@ use anyhow::Result;
use tinyagents::harness::cache::InMemoryResponseCache;
use tinyagents::harness::context::{RunConfig, RunContext};
use tinyagents::harness::events::EventSink;
use tinyagents::harness::message::Message as TaMessage;
use tinyagents::harness::middleware::{
BudgetLimits, BudgetMiddleware, ContextCompressionMiddleware, MessageTrimMiddleware,
PromptCacheGuardMiddleware, ToolPolicyMiddleware as TaToolPolicyMiddleware,
BudgetLimits, BudgetMiddleware, ContextCompressionMiddleware, PromptCacheGuardMiddleware,
ToolPolicyMiddleware as TaToolPolicyMiddleware,
};
use tinyagents::harness::model::CapabilitySet;
use tinyagents::harness::runtime::{AgentHarness, RunPolicy, UnknownToolPolicy};
use tinyagents::harness::steering::{SteeringCommand, SteeringHandle};
use tinyagents::harness::steering::SteeringHandle;
use tinyagents::harness::store::StoreRegistry;
use tinyagents::harness::summarization::TrimStrategy;
use tinyagents::harness::workspace::WorkspaceDescriptor;
use tinyagents::registry::{
CapabilityRegistry, ComponentKind, DiagnosticSeverity, RegistryDiagnostic, RegistrySnapshot,
@@ -70,11 +70,14 @@ use model::ThinkingForwarder;
#[allow(unused_imports)] // Wired into the recall/retrieval facade in workstream 09.2.
pub(crate) use embeddings::ProviderEmbeddingModel;
pub(crate) use middleware::{HandoffConfig, SuperContextConfig, TurnContextMiddleware};
pub(crate) use middleware::{
HandoffConfig, SuperContextConfig, TranscriptSnapshotSink, TurnContextMiddleware,
};
use model::ProviderModel;
pub(crate) use observability::SubagentScope;
use observability::{
CapPauser, IterationCursor, OpenhumanEventBridge, ToolFailureMap, ToolNameMap,
CapPauser, IterationCursor, OpenhumanEventBridge, ProviderUsageCarry, ToolFailureMap,
ToolNameMap,
};
pub(crate) use run_cancellation_context::{current_run_cancellation, with_run_cancellation};
#[cfg(test)]
@@ -102,34 +105,6 @@ pub(crate) struct ToolPolicyEnforcement {
pub agent_definition_id: String,
}
/// Drain the run queue's pending steer messages and forward them to the
/// tinyagents [`SteeringHandle`] as injected user turns (the harness applies
/// them to the working transcript at the next iteration checkpoint). This is the
/// bridge behind the `steer_subagent` / mid-flight-steering feature.
async fn forward_steers(queue: &RunQueue, handle: &SteeringHandle) {
for msg in queue.drain_steers().await {
handle.send(SteeringCommand::InjectMessage(TaMessage::user(format!(
"[User steering message]: {}",
msg.text
))));
}
}
/// Forward any queued **collect** messages (orchestrator/monitor lines enqueued
/// via `QueueMode::Collect`) into the run as injected user turns so they reach the
/// next LLM call as additional context. The in-house loop drained these each
/// iteration (`drain_collects`); the tinyagents rewrite wired only `forward_steers`
/// (issue #4249), so monitor lines never reached the model. Mirrors the legacy
/// `[Additional context from user]:` framing the model was taught to read.
async fn forward_collects(queue: &RunQueue, handle: &SteeringHandle) {
for msg in queue.drain_collects().await {
handle.send(SteeringCommand::InjectMessage(TaMessage::user(format!(
"[Additional context from user]: {}",
msg.text
))));
}
}
/// Build the harness [`RunPolicy`] for an openhuman turn.
///
/// The loop enforces limits from `self.policy.limits` (not the per-run
@@ -242,6 +217,13 @@ pub(crate) struct TinyagentsTurnOutcome {
/// should summarize a resumable checkpoint rather than treat `text` as a
/// final answer — the tinyagents analogue of the legacy cap checkpoint seam.
pub hit_cap: bool,
/// Set (with the root-cause halt summary) when the repeated-tool-failure /
/// repeat-progress circuit breaker halted the run before a natural finish.
/// The sub-agent runner surfaces this as `SubagentRunStatus::Incomplete`
/// (#4466) so a parent does NOT treat a halted child as a clean completion.
/// `text` already carries this same summary; the flag lets the status mapper
/// distinguish a breaker halt from a genuine final answer.
pub breaker_halt: Option<String>,
/// Per-tool-call execution outcomes (success + raw result content), keyed by
/// provider call id, captured at the tool boundary. The harness folds a tool
/// result into a `Message::tool` that drops its `error` flag, so this is the
@@ -321,11 +303,28 @@ pub(crate) async fn run_turn_via_tinyagents(
);
let input = convert::history_to_messages(&history);
// Explicit persistence boundary (issue #4455): the request transcript length,
// captured *before* the run consumes `input`. Everything the harness appends
// after this index — assistant/tool rounds plus any mid-turn steer messages —
// is this turn's persisted `conversation`. Anchoring on this index instead of
// the last-user-message suffix keeps injected steers (which move that
// boundary) from truncating persisted history.
let request_base_len = input.len();
// Box the (large) harness drive future — see `run_turn_via_tinyagents_shared`.
let run = match Box::pin(harness.invoke(&(), (), config, input)).await {
Ok(run) => run,
Err(e) => {
if let Some(original) = error_slot.lock().unwrap().take() {
// #4469 item 3: recover from a poisoned slot instead of panicking.
// A thread that panicked mid-run while holding this mutex would
// otherwise turn every subsequent error-recovery read into a second
// panic, masking the original provider failure. `into_inner` yields
// the guarded value regardless of poison so we still re-surface the
// typed error.
if let Some(original) = error_slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
{
return Err(original);
}
return Err(anyhow::anyhow!("tinyagents harness run failed: {e}"));
@@ -334,8 +333,16 @@ pub(crate) async fn run_turn_via_tinyagents(
let text = run.text().unwrap_or_default();
let out_history = convert::messages_to_history(&run.messages);
let conversation =
convert::messages_to_conversation(convert::messages_since_last_user(&run.messages));
let conversation = convert::messages_to_conversation(convert::messages_since_request(
&run.messages,
request_base_len,
));
tracing::debug!(
request_base_len,
transcript_len = run.messages.len(),
persisted_messages = run.messages.len().saturating_sub(request_base_len),
"[tinyagents] persisting post-request transcript (thin path; steer-safe boundary)"
);
Ok(TinyagentsTurnOutcome {
text,
@@ -354,6 +361,8 @@ pub(crate) async fn run_turn_via_tinyagents(
),
early_exit_tool: None,
hit_cap: false,
// This thin (test-only) variant does not install the breaker middleware.
breaker_halt: None,
// This thin variant carries no per-call outcome capture middleware.
tool_outcomes: Vec::new(),
})
@@ -368,8 +377,14 @@ pub(crate) async fn run_turn_via_tinyagents(
/// advertised spec so the same `Arc`-shared tools the legacy loop runs are
/// reused without cloning.
///
/// `allowed` is the callable tool-name whitelist (empty = every tool visible in
/// `tool_sets`); each callable tool is advertised via its own `spec()`.
/// `allowed` is the callable tool-name whitelist. Its semantics are
/// **fail-closed** (issue #4452): `None` means "no filter supplied" → every tool
/// visible in `tool_sets` is registered; `Some(set)` registers *exactly* the
/// named tools, so `Some(empty)` is an explicit **deny-all** (zero tools). This
/// distinction is what stops a tool-less sub-agent (`ToolScope::Named([])`, a
/// zero-match `skill_filter`, or a `named` list that resolves to nothing) from
/// silently inheriting the parent's full tool surface (shell/file-write/spawn).
/// Each registered tool is advertised via its own `spec()`.
///
/// When `on_progress` is `Some`, the run streams (`invoke_streaming_in_context`)
/// and a [`OpenhumanEventBridge`] mirrors the harness event stream onto
@@ -378,13 +393,29 @@ pub(crate) async fn run_turn_via_tinyagents(
/// produced. Pass `None` for fire-and-forget turns (channel/sub-agent) that
/// only need the final text.
///
/// When `context_window` is known, a [`MessageTrimMiddleware`] keeps history
/// under budget (autocompaction parity).
/// When `context_window` is known, an
/// [`ImageAwareMessageTrimMiddleware`](middleware::ImageAwareMessageTrimMiddleware)
/// keeps history under budget (autocompaction parity).
///
/// `run_queue` forwards mid-flight steer messages into the run; `subagent_scope`
/// re-scopes progress to the `Subagent*` variants (child runs); `early_exit_tools`
/// name the tools that pause the loop (e.g. `ask_user_clarification`) and surface
/// the question via [`TinyagentsTurnOutcome::early_exit_tool`].
/// True when `name` is a sub-agent spawn/delegation tool that a **child** run
/// must never be able to invoke (issue #4452). Mirrors the caller-side strip in
/// `subagent_runner::tool_prep::is_subagent_spawn_tool` plus the worker-thread
/// spawn, re-asserted at registration as defense-in-depth so a misconfigured
/// allowlist cannot reintroduce sub-agent spawning into a nested run. Kept local
/// to this seam (rather than importing the `pub(super)` runner helper) so the
/// invariant travels with the registration site that enforces it.
fn is_subagent_spawn_or_delegate_tool(name: &str) -> bool {
name == "spawn_subagent"
|| name.starts_with("delegate_")
|| name == "use_tinyplace"
|| name == "agent_prepare_context"
|| name == "spawn_worker_thread"
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_turn_via_tinyagents_shared(
provider: Arc<dyn Provider>,
@@ -392,7 +423,7 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
temperature: f64,
history: Vec<ChatMessage>,
tool_sets: Vec<Arc<Vec<Box<dyn crate::openhuman::tools::Tool>>>>,
allowed: HashSet<String>,
allowed: Option<HashSet<String>>,
max_iterations: usize,
on_progress: Option<Sender<AgentProgress>>,
subagent_scope: Option<SubagentScope>,
@@ -405,6 +436,15 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
tool_policy: Option<ToolPolicyEnforcement>,
workspace_descriptor: Option<WorkspaceDescriptor>,
deterministic_cacheable: bool,
// #4457 (defect C): when `true`, the seam does NOT emit the terminal
// `TurnCompleted` — the caller emits it itself *after* its post-run wrap-up
// (e.g. the chat/session path streams a cap/#4093 checkpoint via
// `summarize_turn_wrapup` after this seam returns, so a seam-level emit here
// would land `turn_active = false` before that checkpoint finishes
// streaming, and the web bridge would record two ledger events + two
// Completed upserts). Callers with no post-run streaming (channel/CLI) pass
// `false` and rely on this seam's emit for parity with the legacy engine.
defer_turn_completed_to_caller: bool,
) -> Result<TinyagentsTurnOutcome> {
// `0` means "unset" → the legacy default (a native-bus / test convention);
// otherwise the harness model-call cap would be zero and abort the run before
@@ -415,6 +455,7 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
cursor,
tool_names,
failure_map,
provider_usage_carry,
error_slot,
halt_summary,
tool_outcome_sink,
@@ -513,6 +554,14 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
);
let input = convert::history_to_messages(&history);
// Explicit persistence boundary (issue #4455): the request transcript length,
// captured *before* the run consumes `input`. The turn's persisted
// `conversation` is everything appended past this index — assistant/tool
// rounds plus any mid-turn steer/collect messages injected as user turns.
// Anchoring here (instead of the last-user-message suffix) keeps injected
// steers from moving the boundary and truncating persisted history on both
// the parent (`session/turn/core.rs`) and subagent (`subagent_runner`) paths.
let request_base_len = input.len();
// Build the run context: an optional event sink feeds the progress/cost
// bridge (streaming) and/or the model-call-cap pauser; the shared steering
@@ -561,8 +610,11 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
// `TurnCompleted` after the run (the harness event stream the bridge mirrors
// has no run-completed event). Parent turns only — a sub-agent turn reports
// via its `Subagent*` events, not a top-level `TurnCompleted`.
let turn_completed_sink = subagent_scope
.is_none()
//
// #4457 (defect C): suppressed entirely when `defer_turn_completed_to_caller`
// is set — the caller (chat/session path) emits the single terminal
// `TurnCompleted` itself, after its post-run wrap-up finishes streaming.
let turn_completed_sink = (subagent_scope.is_none() && !defer_turn_completed_to_caller)
.then(|| on_progress.clone())
.flatten();
// A sink is needed to mirror progress (bridge), to observe model-call
@@ -579,22 +631,30 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
let journal_run_id = journal::mint_run_id();
let events = Some(EventSink::with_stream_id(journal_run_id.as_str()));
let bridge = match (&events, on_progress) {
(Some(events), Some(tx)) => {
let bridge = OpenhumanEventBridge::with_scope(
Some(tx),
model,
max_iterations,
subagent_scope.clone(),
cursor.clone(),
tool_names.clone(),
failure_map.clone(),
);
events.subscribe(bridge.clone());
Some(bridge)
}
_ => None,
};
// Attach the event bridge for EVERY turn — including an unobserved
// (`on_progress = None`) background/cron turn (#4467, item 3). The bridge's
// `record_usage` feeds the global cost tracker on each `UsageRecorded` event
// *during* the run, so a run that burns N model calls and then fails still
// contributes that spend to the wallet/cost surfaces — the post-run
// `record_unobserved_turn_usage` fallback below only runs on the success path
// and never sees a failed run's usage. With `on_progress = None` the bridge
// still records cost but its progress `send`s are inert no-ops, so there is
// no spurious streaming. `events` is created unconditionally above, so the
// bridge is always present.
let bridge = events.as_ref().map(|events| {
let bridge = OpenhumanEventBridge::with_scope(
on_progress,
model,
max_iterations,
subagent_scope.clone(),
cursor.clone(),
tool_names.clone(),
failure_map.clone(),
provider_usage_carry.clone(),
);
events.subscribe(bridge.clone());
bridge
});
// Cap pauser: stop gracefully at the model-call budget (returning the partial
// transcript) so the caller can summarize a checkpoint instead of erroring.
@@ -629,33 +689,62 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
// Steering: attach the shared handle (when present), drain any already-queued
// steer messages into it (so a pre-run steer lands before the first model
// call), and forward mid-flight steers via a poller aborted when the run
// returns. The same handle carries the early-exit `Pause`.
let mut registered_steering_task_id = None;
let steering_forwarder = if let Some(handle) = handle {
if let Some(scope) = &subagent_scope {
// call), and forward mid-flight steers via a poll loop. The same handle
// carries the early-exit `Pause`.
//
// Best-effort thread label for the delivery/requeue observability events and
// the metadata on any requeued steer: a sub-agent uses its task id; the
// interactive/channel parent turn reads the task-local turn origin.
let steer_thread_label = subagent_scope
.as_ref()
.map(|s| s.task_id.clone())
.or_else(|| match crate::openhuman::agent::turn_origin::current() {
Some(crate::openhuman::agent::turn_origin::AgentTurnOrigin::WebChat {
thread_id,
..
}) => Some(thread_id),
Some(crate::openhuman::agent::turn_origin::AgentTurnOrigin::ExternalChannel {
reply_target,
..
}) => Some(reply_target),
_ => None,
})
.unwrap_or_default();
// The forwarder is wrapped in an abort-on-drop RAII guard (issue #4456): its
// `Drop` aborts the poll task, deregisters the sub-agent steering handle, and
// drains residual (delivered-but-unapplied) steers back into the session run
// queue. Because the guard is held across the drive future, that cleanup runs
// identically on normal return, error, AND drop-cancellation — the previous
// manual `forwarder.abort()` after the drive future only ran on normal
// return, so a cancelled turn (web interrupt / sub-agent abort, both
// drop-based) leaked a forwarder task that looped forever and raced the next
// turn for the shared run queue.
let steering_forwarder_guard = if let Some(handle) = handle {
let registry_task_id = if let Some(scope) = &subagent_scope {
let task_id = orchestration::TaskId::new(scope.task_id.clone());
orchestration::shared_steering_registry().register(task_id.clone(), handle.clone());
tracing::debug!(
task_id = scope.task_id.as_str(),
"[tinyagents] registered subagent steering handle"
);
registered_steering_task_id = Some(task_id);
}
Some(task_id)
} else {
None
};
// Pre-run drain so a steer/collect queued before the turn started lands
// ahead of the first model call.
if let Some(queue) = run_queue.clone() {
forward_steers(&queue, &handle).await;
forward_collects(&queue, &handle).await;
steering_forwarder::forward_steers(&queue, &handle, &steer_thread_label).await;
steering_forwarder::forward_collects(&queue, &handle, &steer_thread_label).await;
}
ctx = ctx.with_steering(handle.clone());
run_queue.map(|queue| {
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
forward_steers(&queue, &handle).await;
forward_collects(&queue, &handle).await;
}
})
})
Some(steering_forwarder::SteeringForwarderGuard::new(
handle,
run_queue,
registry_task_id,
steer_thread_label.clone(),
))
} else {
None
};
@@ -673,16 +762,12 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
}
})
.await;
if let Some(forwarder) = steering_forwarder {
forwarder.abort();
}
if let Some(task_id) = registered_steering_task_id {
orchestration::shared_steering_registry().deregister(&task_id);
tracing::debug!(
task_id = task_id.as_str(),
"[tinyagents] deregistered subagent steering handle"
);
}
// Drive future returned: run cleanup now (abort poll task + deregister +
// requeue residual steers) rather than deferring to end-of-scope so the poll
// loop cannot deliver into the no-longer-drained handle during post-run
// journal/mapping work. On a *cancelled* turn this line is never reached; the
// guard's `Drop` fires as the turn future unwinds, giving identical cleanup.
drop(steering_forwarder_guard);
let run = match run_result {
Ok(run) => run,
Err(e) => {
@@ -691,11 +776,18 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
if let Some(journal) = &turn_journal {
journal.finish_failed(&e.to_string()).await;
}
// Prefer the original typed provider error (preserves `AgentError`
// downcasts the caller relies on) over the harness's string wrap.
if let Some(original) = error_slot.lock().unwrap().take() {
return Err(original);
}
// #4457 (defect B): map the run's *own* definitively-non-provider
// failure kinds FIRST, before consulting `error_slot`. The slot
// preserves the last provider error the model adapter saw — but the
// adapter now clears it on every successful call (see
// `ProviderModel::chat`/`stream`), so a stale slot should not exist
// here. Ordering the cap/depth mappings ahead of the slot is
// defense-in-depth: a run that failed on the model-call cap or a
// spawn-depth limit is not a provider error, so it must surface as
// `MaxIterationsExceeded` / the depth error rather than a leftover
// provider error (wrong classification, wrong Sentry suppression,
// wrong user message).
//
// The model-call cap (when not pausing gracefully — the channel/CLI
// path) maps to the typed `AgentError::MaxIterationsExceeded` so
// callers downcast it (Sentry skip) and render the canonical
@@ -703,6 +795,10 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
// legacy `ErrorCheckpoint`.
if let tinyagents::TinyAgentsError::LimitExceeded(msg) = &e {
if msg.contains("model call") {
tracing::debug!(
model,
"[tinyagents] run hit the model-call cap; mapping to MaxIterationsExceeded (not consulting error_slot) — #4457 defect B"
);
return Err(anyhow::Error::new(
crate::openhuman::agent::error::AgentError::MaxIterationsExceeded {
max: max_iterations,
@@ -713,6 +809,24 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
if let Some(depth_err) = tinyagents_depth_error(&e) {
return Err(anyhow::Error::new(depth_err));
}
// Otherwise prefer the original typed provider error (preserves
// `AgentError` downcasts the caller relies on) over the harness's
// string wrap — this is where a genuine model/provider failure that
// halted the run is re-surfaced with its real classification.
// #4469 item 3: `into_inner` recovers a poisoned slot so a panic in
// one run can't cascade into a second panic here that would mask the
// original typed provider error.
if let Some(original) = error_slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
{
tracing::debug!(
model,
"[tinyagents] re-surfacing typed provider error from error_slot as the run failure — #4457 defect B"
);
return Err(original);
}
return Err(anyhow::anyhow!("tinyagents harness run failed: {e}"));
}
};
@@ -781,6 +895,10 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
// Terminal turn event (parity with the legacy engine's `progress::emit`): the
// harness stream has no run-completed event, so emit `TurnCompleted` here with
// the model-call count as the iteration total. Parent turns only; best-effort.
// `turn_completed_sink` is `None` for sub-agent turns AND when the caller
// opted to emit the terminal event itself after its post-run wrap-up
// (`defer_turn_completed_to_caller`, #4457 defect C) — so this is the single
// emission point for callers with no post-run streaming (channel/CLI).
if let Some(sink) = &turn_completed_sink {
let _ = sink.try_send(AgentProgress::TurnCompleted {
iterations: run.model_calls as u32,
@@ -858,8 +976,17 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
None => (None, run.text().unwrap_or_default()),
};
if let Some(summary) = breaker_halt {
text = summary;
// Carry the breaker halt onto the outcome so the sub-agent runner can report
// `Incomplete` (#4466). `text` is overridden with the same root-cause summary
// so callers with no breaker-awareness still surface the cause, not an empty
// last-model reply.
if let Some(summary) = &breaker_halt {
tracing::info!(
model,
subagent = subagent_scope.is_some(),
"[tinyagents] run halted by circuit breaker; surfacing as breaker_halt (#4466)"
);
text = summary.clone();
}
let tool_outcomes = tool_outcome_sink
@@ -867,12 +994,23 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
.map(|guard| guard.clone())
.unwrap_or_default();
let conversation = convert::messages_to_conversation(convert::messages_since_request(
&run.messages,
request_base_len,
));
tracing::debug!(
model,
request_base_len,
transcript_len = run.messages.len(),
persisted_messages = run.messages.len().saturating_sub(request_base_len),
subagent = subagent_scope.is_some(),
"[tinyagents] persisting post-request transcript (shared path; steer-safe boundary)"
);
Ok(TinyagentsTurnOutcome {
text,
history: convert::messages_to_history(&run.messages),
conversation: convert::messages_to_conversation(convert::messages_since_last_user(
&run.messages,
)),
conversation,
model_calls: run.model_calls,
tool_calls: run.tool_calls,
input_tokens,
@@ -881,6 +1019,7 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
charged_amount_usd,
early_exit_tool,
hit_cap,
breaker_halt,
tool_outcomes,
})
}
@@ -916,10 +1055,15 @@ struct AssembledTurnHarness {
/// writes it on tool-call start; the event bridge reads it to label the
/// tool-argument fragments it now projects off the crate stream.
tool_names: ToolNameMap,
/// Shared `call_id → (success, failure)` side-channel: the tool-outcome
/// capture middleware classifies each outcome; the event bridge reads it to
/// project real success + a user-facing failure onto `ToolCallCompleted`.
/// Shared `call_id → (success, failure, elapsed_ms, output_chars)`
/// side-channel: the tool-outcome capture middleware classifies each outcome
/// + records its duration/output size; the event bridge reads it to project
/// real success + a user-facing failure + timing onto `ToolCallCompleted`.
failure_map: ToolFailureMap,
/// Shared FIFO carry of per-call provider `UsageInfo` (charged USD + context
/// window): the model adapter pushes, the event bridge pops when recording
/// usage — restores charged-USD precedence on the tinyagents path (#4467).
provider_usage_carry: ProviderUsageCarry,
/// Recovers the original (downcastable) provider error on run failure.
error_slot: crate::openhuman::tinyagents::model::ProviderErrorSlot,
/// Root-cause summary recorded by the repeated-tool-failure breaker.
@@ -965,7 +1109,7 @@ fn assemble_turn_harness(
model: &str,
temperature: f64,
tool_sets: Vec<Arc<Vec<Box<dyn crate::openhuman::tools::Tool>>>>,
allowed: HashSet<String>,
allowed: Option<HashSet<String>>,
max_iterations: usize,
on_progress: Option<Sender<AgentProgress>>,
subagent_scope: Option<SubagentScope>,
@@ -1013,10 +1157,16 @@ fn assemble_turn_harness(
// tool-call start (the crate `ToolDelta` carries none), the bridge reads it
// to label the argument fragments now streamed via `MessageDelta.tool_call`.
let tool_names: ToolNameMap = Arc::default();
// Shared FIFO carry of per-call provider `UsageInfo`: the model adapter
// pushes each successful response's usage (charged USD + context window +
// cache-creation/reasoning tokens the crate `Usage` drops), the event bridge
// pops it when recording that call's usage (#4467, item 1).
let provider_usage_carry: ProviderUsageCarry = Arc::default();
// Keep a provider handle for the context-window summarizer (the run consumes
// the other clone into the `ProviderModel`).
let summary_provider = provider.clone();
let mut provider_model = ProviderModel::new(provider, model, temperature);
let mut provider_model = ProviderModel::new(provider, model, temperature)
.with_usage_carry(provider_usage_carry.clone());
// Cap the model's per-call output budget (parity with the legacy engine,
// which bounded the main agent at `AGENT_TURN_MAX_OUTPUT_TOKENS` and each
// sub-agent at its `max_turn_output_tokens`). Without this the tinyagents
@@ -1140,6 +1290,19 @@ fn assemble_turn_harness(
)));
}
// Repeat-progress breaker (issue #4463, restoring #4088 / #4095): the failure
// breaker above resets on every success, so a model looping on a *successful*
// no-op tool or re-emitting an identical narration+call never trips it. This
// guard halts on identical successful `(tool, args)` batches / identical
// outputs, sharing the same halt-summary slot + steering handle. Polling tools
// (`wait_subagent`) stay exempt.
if let Some(handle) = &handle {
harness.push_middleware(Arc::new(middleware::RepeatProgressMiddleware::new(
handle.clone(),
halt_summary.clone(),
)));
}
// Policy-driven stop hooks (budget cap, thread-goal budget, ad-hoc iteration
// ceiling): fire after each model call and pause the run on the first stop
// vote. Replaces the legacy tool-call-loop firing point.
@@ -1162,7 +1325,23 @@ fn assemble_turn_harness(
.map(|h| EarlyExitHook::new(h.clone()));
// Register one adapter per unique callable tool name found across the shared
// sets (newest set wins on a name clash; `allowed` empty = all visible).
// sets (newest set wins on a name clash). Allowlist semantics are
// **fail-closed** (issue #4452): `allowed == None` → no filter, every visible
// tool registers; `allowed == Some(set)` → register *exactly* the named
// tools, so `Some(empty)` denies all. This is what keeps a deliberately
// tool-less sub-agent (`ToolScope::Named([])`, a zero-match `skill_filter`,
// or a `named` list that resolves to nothing) from silently inheriting the
// parent's full tool surface (shell/file-write/spawn) — the old
// `allowed.is_empty() || allowed.contains(name)` predicate was fail-open.
let is_subagent_run = subagent_scope.is_some();
if let Some(set) = &allowed {
if set.is_empty() {
tracing::warn!(
subagent = is_subagent_run,
"[subagent] tool allowlist resolved empty — registering no tools"
);
}
}
let mut seen_candidates: HashSet<String> = HashSet::new();
let candidate_names: Vec<String> = tool_sets
.iter()
@@ -1176,7 +1355,25 @@ fn assemble_turn_harness(
.collect();
let mut registered: HashSet<String> = HashSet::new();
for name in candidate_names.iter().map(String::as_str) {
if !registered.contains(name) && (allowed.is_empty() || allowed.contains(name)) {
// Fail-closed allowlist: `None` admits everything, `Some(set)` admits only
// its members (empty set → nothing).
let admitted = match &allowed {
None => true,
Some(set) => set.contains(name),
};
// Defense-in-depth (issue #4452): a sub-agent must NEVER be handed a
// spawn/delegate tool, regardless of what the resolved allowlist contains.
// Re-assert the invariant here at registration time (not just on the
// caller's `allowed_indices`) so a misbuilt allowlist can't reintroduce
// `spawn_subagent`/`delegate_*`/worker-thread spawning into a child run.
let spawn_stripped = is_subagent_run && is_subagent_spawn_or_delegate_tool(name);
if spawn_stripped {
tracing::warn!(
tool = name,
"[subagent] refusing to register spawn/delegate tool on sub-agent run"
);
}
if !registered.contains(name) && admitted && !spawn_stripped {
if let Some(mut adapter) = SharedToolAdapter::for_name(tool_sets.clone(), name) {
if early_exit_set.contains(name) {
if let Some(hook) = &early_exit_hook {
@@ -1310,7 +1507,7 @@ fn assemble_turn_harness(
harness.push_middleware(Arc::new(
middleware::OpenHumanToolExposureShadowMiddleware::new(
&candidate_names,
&allowed,
allowed.as_ref(),
exposure_tags,
),
));
@@ -1336,6 +1533,26 @@ fn assemble_turn_harness(
// by the crate `PromptCacheGuardMiddleware`; the warn-only CacheAlign shadow
// was deleted in C3.) Tool-result caps read the SDK registry policy snapshot,
// not the OpenHuman-side tool lookup.
// Capture each tool call's real success + content before the harness folds the
// result into a `Message::tool` that drops the failure flag, so the turn can
// build honest per-call `ToolCallRecord`s (post-turn hooks + cap checkpoint).
//
// REVERSE-ORDER RULE (issue #4464): the crate runs `after_tool` in REVERSE
// registration order, so the LATER a middleware is pushed the EARLIER its
// `after_tool` runs. This capture must observe the FINAL (summarized/capped)
// content, so it is pushed BEFORE `context_mw.install` (which registers the
// handoff + tool-output budget/caps) — that way its `after_tool` runs AFTER
// those caps, not before. Registering it after `install` (the pre-#4464 bug)
// made its `after_tool` run first and record the full raw payload of every
// call, bloating the per-turn sink and feeding failure classification /
// `ToolCallRecord.output_summary` pre-cap content.
let tool_outcome_sink: ToolOutcomeSink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let failure_map: ToolFailureMap = Arc::default();
harness.push_middleware(Arc::new(middleware::ToolOutcomeCaptureMiddleware::new(
tool_outcome_sink.clone(),
failure_map.clone(),
)));
let tool_policies = harness.tools().policies();
context_mw.install(&mut harness, tool_policies);
@@ -1394,10 +1611,12 @@ fn assemble_turn_harness(
// transcript into a single LLM-generated system summary (keeping system
// messages + the recent window verbatim). This is keyed to whatever model
// the turn is running on, preserving the legacy context threshold.
// 2. `MessageTrimMiddleware` — a deterministic, no-extra-LLM-call hard cap.
// 2. `ImageAwareMessageTrimMiddleware` — a deterministic, no-extra-LLM-call
// hard cap (issue #4462; replaces the crate `MessageTrimMiddleware`).
// Pushed **after** compression (so `before_model` runs compression first),
// it front-trims to budget only as a last resort when even the summary +
// recent window still overflow.
// it front-trims to the legacy proportional budget only as a last resort
// when even the summary + recent window still overflow — image markers
// priced flat, system messages never dropped, evictions logged.
//
// The LLM summarization step honors the `[context].enabled` /
// `autocompact_enabled` opt-outs (a disabled config must not spend summarizer
@@ -1411,24 +1630,40 @@ fn assemble_turn_harness(
let mut compression_mw: Option<Arc<ContextCompressionMiddleware>> = None;
if let Some(window) = context_window.filter(|w| *w > 0) {
if autocompact_enabled {
let mw = Arc::new(ContextCompressionMiddleware::with_summarizer(
summarize::summarization_policy(window),
let policy = summarize::summarization_policy(window);
// Wrap the LLM-backed summarizer in a fault-tolerant, per-turn-caching
// adapter (issue #4461): a summarizer failure must no longer abort the
// turn (warn + circuit-breaker + deterministic trim instead), and an
// identical re-issued input slice must not re-run the summarizer LLM.
let summarizer = summarize::FaultTolerantCachingSummarizer::new(
Box::new(summarize::ProviderModelSummarizer::new(
summary_provider,
model,
temperature,
)),
&policy,
);
let mw = Arc::new(ContextCompressionMiddleware::with_summarizer(
policy,
Box::new(summarizer),
));
harness.push_middleware(mw.clone());
compression_mw = Some(mw);
}
let budget = window.saturating_sub(
crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS as u64,
);
harness.push_middleware(Arc::new(MessageTrimMiddleware::new(
TrimStrategy::MaxTokens(budget.max(1024)),
)));
// Deterministic hard-cap trim (issue #4462). The crate
// `MessageTrimMiddleware` regressed three legacy `token_budget.rs`
// guards: it priced a base64 image at ~2M tokens (chars/4) and could
// evict system messages, it reordered system messages to the front, and
// its budget was the fixed `window AGENT_TURN_MAX_OUTPUT_TOKENS`
// (floored 1024) that collapses an 8k local model's input budget from
// ~7373 to 1024. Our seam-owned `ImageAwareMessageTrimMiddleware`
// restores all three: image markers priced at a flat cost, the
// proportional reply reserve, system messages always kept in place, and a
// grep-able warn with drop/token counts on any eviction.
harness.push_middleware(Arc::new(
middleware::ImageAwareMessageTrimMiddleware::for_context_window(window),
));
}
// SDK-owned tool-policy projection (issue #4249 / tinyagents-full-migration
@@ -1442,6 +1677,20 @@ fn assemble_turn_harness(
TaToolPolicyMiddleware::new(harness.tools().policies()).require_sandbox(true),
));
// Schema-guard (issue #4451): the crate runs a **fatal** JSON-schema gate on
// every tool call between `before_tool` and the tool-wrap onion — a missing
// required field / wrong type / bad enum returns `TinyAgentsError::Validation`
// and aborts the whole turn (`chat_error`). This middleware re-runs the same
// validation in `before_tool`; on failure it records a descriptive error and
// rewrites the args to a schema-satisfying stub (so the crate gate passes),
// then its `wrap_tool` hook short-circuits the flagged call with a synthetic
// failed `ToolResult` before the stub can reach the tool — restoring the
// legacy engine's "bad args → recoverable tool error the model self-corrects
// on" behaviour. Installed as the **outermost** tool wrap so an invalid call
// becomes a tool error before approval/policy wraps ever see the stub args.
let schema_guard = Arc::new(middleware::SchemaGuardMiddleware::new(tool_sets.clone()));
harness.push_tool_middleware(schema_guard.clone());
// Human-in-the-loop approval as a named tool middleware (issue #4249,
// Phase 1): an external-effect tool intercepts through the global
// `ApprovalGate`, a denial short-circuits with a model-consumable result, and
@@ -1458,16 +1707,6 @@ fn assemble_turn_harness(
tool_sets.clone(),
)));
// Capture each tool call's real success + content before the harness folds the
// result into a `Message::tool` that drops the failure flag, so the turn can
// build honest per-call `ToolCallRecord`s (post-turn hooks + cap checkpoint).
let tool_outcome_sink: ToolOutcomeSink = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let failure_map: ToolFailureMap = Arc::default();
harness.push_middleware(Arc::new(middleware::ToolOutcomeCaptureMiddleware::new(
tool_outcome_sink.clone(),
failure_map.clone(),
)));
// Builder-configured tool policy (`.tool_policy()`), enforced at the tool
// boundary. The in-house engine ran this in `agent_tool_exec`; the tinyagents
// path bypassed it, so a deny/require-approval silently no-opped (security
@@ -1484,17 +1723,40 @@ fn assemble_turn_harness(
)));
}
// Malformed-argument recovery (`before_tool`): coerce a call's non-object
// arguments (invalid JSON parses to Null) to `{}` so a single bad tool call is
// recoverable — the harness would otherwise reject it against an object schema
// and abort the whole turn. Engine parity.
harness.push_middleware(Arc::new(middleware::ArgRecoveryMiddleware));
// Credential scrubbing (issue #4453): redact credential-shaped secrets out of
// every tool result. The legacy engine ran `scrub_credentials` over every
// tool output before it entered model context; the tinyagents path dropped
// that call site. Installed as the **innermost** tool wrap (pushed last) so
// it scrubs the RAW tool result before any outer wrap, the `after_tool`
// chain (summarization/caps), the transcript push, or the tool-outcome
// capture sink can observe the unredacted content — covering the parent,
// sub-agent, persisted-transcript, and `ToolCallOutcome` surfaces by
// construction since every path shares this seam.
harness.push_tool_middleware(Arc::new(middleware::CredentialScrubMiddleware::new()));
// Malformed-argument recovery (`before_tool`): repair a call's non-object
// arguments before the crate's schema gate — decode JSON-encoded-string args
// (optionally markdown-fenced) to an object, or coerce to `{}` only when the
// tool schema has no required fields (engine parity). A non-object against a
// required-field schema is left untouched so the schema-guard tool-error path
// handles it instead of forcing a fatal `"<field> is required"` abort. Runs
// before `SchemaGuardMiddleware::before_tool` (registered next) validates.
harness.push_middleware(Arc::new(middleware::ArgRecoveryMiddleware::new(
tool_sets.clone(),
)));
// Schema-guard `before_tool` (see the tool-wrap registration above): runs the
// crate's schema validation and, on failure, flags the call + stubs its args
// so the fatal gate passes and `wrap_tool` can short-circuit it. Registered
// last so it validates the arguments `ArgRecoveryMiddleware` just repaired.
harness.push_middleware(schema_guard);
AssembledTurnHarness {
harness,
cursor,
tool_names,
failure_map,
provider_usage_carry,
error_slot,
halt_summary,
tool_outcome_sink,
+156 -10
View File
@@ -19,10 +19,13 @@ use tinyagents::harness::tool::{ToolCall as TaToolCall, ToolDelta};
use tinyagents::harness::usage::Usage;
use tokio::sync::mpsc::{Sender, UnboundedSender};
use super::observability::{IterationCursor, SubagentScope, ToolNameMap};
use super::abort_guard::AbortOnDrop;
use super::observability::{IterationCursor, ProviderUsageCarry, SubagentScope, ToolNameMap};
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::inference::provider::thread_context::{current_thread_id, with_thread_id};
use crate::openhuman::inference::provider::{
ChatMessage, ChatRequest, ChatResponse, Provider, ProviderDelta,
current_route_slot, with_route_slot, ChatMessage, ChatRequest, ChatResponse, Provider,
ProviderDelta,
};
use crate::openhuman::tools::ToolSpec;
@@ -149,6 +152,31 @@ fn build_chat_inputs(
(messages, specs)
}
/// Build a [`PFormatRegistry`](crate::openhuman::agent::pformat::PFormatRegistry)
/// from the tool schemas advertised on a [`ModelRequest`] (issue #4465).
///
/// The text-mode fallback parse needs each tool's positional parameter layout
/// to reconstruct named JSON arguments from a P-Format `name[a|b]` body. The
/// harness always populates `request.tools` (schemas are rendered into the
/// prompt for prompt-guided providers, or advertised natively otherwise), so
/// the registry is available in both modes. An empty registry (no tools
/// advertised) makes the P-Format-aware parser short-circuit to the canonical
/// grammar, so this is behaviour-neutral when there are no tools.
fn pformat_registry_from_request(
request: &ModelRequest,
) -> crate::openhuman::agent::pformat::PFormatRegistry {
request
.tools
.iter()
.map(|t| {
(
t.name.clone(),
crate::openhuman::agent::pformat::PFormatToolParams::from_schema(&t.parameters),
)
})
.collect()
}
/// Translate an openhuman [`ChatResponse`] into a harness [`ModelResponse`]
/// (visible text + tool calls + token usage).
///
@@ -157,9 +185,18 @@ fn build_chat_inputs(
/// dispatcher — so text-mode models drive the tinyagents loop too. The visible
/// text is the prose with any tool-call markup stripped.
///
/// `pformat_registry` carries the advertised tools' positional layouts so the
/// text-mode fallback can recover P-Format (`name[a|b]`) calls that ~10 builtin
/// prompts still teach — the migrated parse path had dropped that grammar and
/// silently lost those calls (issue #4465). It is empty for the native-tool
/// path (where `response.tool_calls` is used directly) and for tool-less turns.
///
/// Unknown-tool recovery is handled by `RunPolicy::unknown_tool`, so the model
/// adapter preserves the provider-requested tool name.
fn response_to_model_response(response: &ChatResponse) -> ModelResponse {
fn response_to_model_response(
response: &ChatResponse,
pformat_registry: &crate::openhuman::agent::pformat::PFormatRegistry,
) -> ModelResponse {
let (visible_text, tool_calls): (String, Vec<TaToolCall>) = if !response.tool_calls.is_empty() {
let calls = response
.tool_calls
@@ -172,7 +209,8 @@ fn response_to_model_response(response: &ChatResponse) -> ModelResponse {
.collect();
(response.text.clone().unwrap_or_default(), calls)
} else if let Some(text) = response.text.as_deref() {
let (prose, parsed) = crate::openhuman::agent::harness::parse_tool_calls(text);
let (prose, parsed) =
crate::openhuman::agent::harness::parse_tool_calls_with_pformat(text, pformat_registry);
if parsed.is_empty() {
(text.to_string(), Vec::new())
} else {
@@ -313,6 +351,12 @@ pub(super) struct ProviderModel {
thinking: Option<ThinkingForwarder>,
/// Preserves the last original provider error for the runner to re-surface.
error_slot: ProviderErrorSlot,
/// FIFO side-channel shared with the event bridge: on each successful chat
/// response the adapter pushes the provider `UsageInfo` (which carries the
/// backend-charged USD + context window + cache-creation/reasoning tokens
/// the crate `Usage` mapping drops), and the bridge pops it when recording
/// that call's usage — restoring charged-USD precedence (#4467, item 1).
usage_carry: ProviderUsageCarry,
/// Capability profile derived from the wrapped provider (issue #4249,
/// Phase 2): lets the crate validate a request against the model's actual
/// capabilities (vision, tool calling, streaming, token limits) *before*
@@ -368,6 +412,7 @@ impl ProviderModel {
max_tokens: None,
thinking: None,
error_slot: Arc::new(Mutex::new(None)),
usage_carry: Arc::default(),
profile,
}
}
@@ -378,6 +423,14 @@ impl ProviderModel {
self.error_slot.clone()
}
/// Attach the shared provider-usage carry the event bridge drains, so the
/// backend-charged USD + context window this adapter observes reach the cost
/// accounting (#4467, item 1). Clone the same handle into the bridge.
pub(super) fn with_usage_carry(mut self, carry: ProviderUsageCarry) -> Self {
self.usage_carry = carry;
self
}
/// Cap the output tokens requested from the provider for every call.
pub(super) fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = Some(max_tokens);
@@ -436,6 +489,9 @@ impl ChatModel<()> for ProviderModel {
) -> tinyagents::Result<ModelResponse> {
let native = self.provider.supports_native_tools();
let (messages, specs) = build_chat_inputs(&request, native);
// Positional layouts for the text-mode P-Format fallback (issue #4465);
// empty (and thus behaviour-neutral) when no tools are advertised.
let pformat_registry = pformat_registry_from_request(&request);
let chat_request = ChatRequest {
messages: &messages,
// Only advertise structured tool specs to native providers. Prompt-
@@ -459,7 +515,21 @@ impl ChatModel<()> for ProviderModel {
.chat(chat_request, &self.model, self.temperature)
.await
{
Ok(response) => response,
Ok(response) => {
// #4457 (defect B): the error slot preserves the last provider
// error for the runner to re-surface as the typed turn failure.
// A call that *succeeds* — including one the provider fallback
// chain recovered after an inner error — must clear any stale
// error so a later, unrelated run failure (e.g. the model-call
// cap) is not misclassified as that recovered provider error.
if self.error_slot.lock().unwrap().take().is_some() {
tracing::debug!(
model = %self.model,
"[models] provider chat succeeded; cleared stale error_slot — #4457 defect B"
);
}
response
}
Err(e) => {
// Classify with OpenHuman's product error taxonomy (issue #4249,
// Workstream 02.2): a permanent config/auth rejection, billing/quota
@@ -499,7 +569,17 @@ impl ChatModel<()> for ProviderModel {
forwarder.emit(reasoning.clone());
}
}
Ok(response_to_model_response(&response))
// Push this call's provider usage onto the shared carry so the event
// bridge records charged USD / context window with provider precedence
// (#4467, item 1). One push per successful response, matching the single
// `UsageRecorded` the crate emits for this call.
if let Some(u) = &response.usage {
self.usage_carry
.lock()
.unwrap_or_else(|p| p.into_inner())
.push_back(u.clone());
}
Ok(response_to_model_response(&response, &pformat_registry))
}
/// Stream the model response, forwarding openhuman's `ProviderDelta` events
@@ -514,19 +594,48 @@ impl ChatModel<()> for ProviderModel {
async fn stream(&self, _state: &(), request: ModelRequest) -> tinyagents::Result<ModelStream> {
let native = self.provider.supports_native_tools();
let (messages, specs) = build_chat_inputs(&request, native);
// Positional layouts for the text-mode P-Format fallback (issue #4465);
// built here so it can move into the `'static` producer task below.
let pformat_registry = pformat_registry_from_request(&request);
let provider = self.provider.clone();
let model = self.model.clone();
let temperature = self.temperature;
let max_tokens = self.max_tokens;
let thinking = self.thinking.clone();
let error_slot = self.error_slot.clone();
// Captured for the spawned producer (task-locals/`self` do not cross the
// spawn): the streaming path pushes provider usage onto the same carry
// the buffered path uses, so charged USD reaches the bridge (#4467, item 1).
let usage_carry = self.usage_carry.clone();
let (item_tx, item_rx) = tokio::sync::mpsc::unbounded_channel::<ModelStreamItem>();
// #4460: the producer below runs in a detached `tokio::spawn`, and
// `tokio::task_local`s do NOT propagate across a spawn boundary. Capture
// the two ambient task-locals the provider call depends on *here*, on the
// caller's task, and re-establish them inside the spawn:
// - `thread_id` → the managed backend's `thread_id` extension
// (`compatible_request::outbound_thread_id`) so streamed requests stay
// attributed to the right chat / prompt-cache group.
// - resolved-route audit slot → so `record_resolved_provider_route`
// calls inside `provider.chat` write back to the caller's scope and the
// channel audit reports the *resolved* route, not the requested one.
let thread_id = current_thread_id();
let route_slot = current_route_slot();
// Label for the abort-on-drop debug log; the moved-in `model` clone is
// consumed by the producer body.
let abort_label = model.clone();
tracing::debug!(
model = %model,
thread_id = thread_id.as_deref().unwrap_or("<none>"),
route_slot = route_slot.is_some(),
"[tinyagents] spawning streamed provider producer; re-establishing task-locals across spawn — #4460"
);
// Producer: run the provider call while forwarding its incremental
// deltas, then emit the terminal item. Everything captured is owned, so
// the task is `'static`.
tokio::spawn(async move {
let producer = async move {
let _ = item_tx.send(ModelStreamItem::Started);
let (delta_tx, mut delta_rx) = tokio::sync::mpsc::channel::<ProviderDelta>(64);
let chat_fut = async {
@@ -563,6 +672,16 @@ impl ChatModel<()> for ProviderModel {
let terminal = match response {
Ok(resp) => {
// #4457 (defect B): a successful streaming call — including
// one recovered by the provider fallback chain — clears any
// stale error preserved in the slot so a later unrelated run
// failure is not misclassified as that recovered error.
if error_slot.lock().unwrap().take().is_some() {
tracing::debug!(
model = %model,
"[models] streaming provider chat succeeded; cleared stale error_slot — #4457 defect B"
);
}
// Fallback for streaming providers that return reasoning only
// on the aggregated response (no incremental thinking
// deltas): emit it once through the native crate stream so
@@ -576,7 +695,16 @@ impl ChatModel<()> for ProviderModel {
));
}
}
ModelStreamItem::Completed(response_to_model_response(&resp))
// Push provider usage onto the shared carry (#4467, item 1),
// mirroring the buffered path — before building the terminal
// item, so it is queued ahead of the crate `UsageRecorded`.
if let Some(u) = &resp.usage {
usage_carry
.lock()
.unwrap_or_else(|p| p.into_inner())
.push_back(u.clone());
}
ModelStreamItem::Completed(response_to_model_response(&resp, &pformat_registry))
}
Err(e) => {
// Streaming failures ride `ModelStreamItem::Failed(String)`, which
@@ -599,10 +727,28 @@ impl ChatModel<()> for ProviderModel {
}
};
let _ = item_tx.send(terminal);
};
// Re-establish the captured task-locals inside the spawned task (#4460).
// `with_thread_id` normalizes an absent id to `None`, so it is a no-op
// when there was no ambient thread; the route slot is only re-scoped when
// an enclosing `with_resolved_provider_route_scope` supplied one.
let handle = tokio::spawn(async move {
let scoped = with_thread_id(thread_id.unwrap_or_default(), producer);
match route_slot {
Some(slot) => with_route_slot(slot, scoped).await,
None => scoped.await,
}
});
let stream = futures_util::stream::unfold(item_rx, |mut rx| async move {
rx.recv().await.map(|item| (item, rx))
// #4460: tie the producer's lifetime to the consumer. Moving the
// abort-on-drop guard into the stream state means that dropping the
// stream (the turn future being hard-cancelled via `AbortHandle`, or
// dropped for any other reason) aborts the in-flight `provider.chat` call
// instead of letting it run — and bill — to completion in the background.
let guard = AbortOnDrop::new(handle, abort_label);
let stream = futures_util::stream::unfold((item_rx, guard), |(mut rx, guard)| async move {
rx.recv().await.map(|item| (item, (rx, guard)))
});
Ok(Box::pin(stream))
}
+162 -44
View File
@@ -49,13 +49,16 @@ pub(crate) type IterationCursor = Arc<AtomicU32>;
/// `tool_name` contract without the forwarder emitting those fragments itself.
pub(crate) type ToolNameMap = Arc<Mutex<std::collections::HashMap<String, String>>>;
/// Shared `call_id → (success, classified failure)` side-channel. The crate's
/// `AgentEvent::ToolCompleted` carries only `call_id` + `tool_name` (no
/// success/error), so `ToolOutcomeCaptureMiddleware::after_tool` — which does
/// see the `ToolResult` — classifies each outcome and writes it here; the bridge
/// reads it when projecting the live `ToolCallCompleted` event, so a failed tool
/// surfaces real `success: false` + a user-facing `failure`. Absent entry (event
/// projected before the middleware ran) falls back to `(true, None)`.
/// Shared `call_id → (success, classified failure, elapsed_ms, output_chars)`
/// side-channel. The crate's `AgentEvent::ToolCompleted` carries only `call_id`
/// + `tool_name` (no success/error, duration, or output size), so
/// `ToolOutcomeCaptureMiddleware::after_tool` — which does see the `ToolResult`
/// (including the executor-measured `elapsed_ms` and the rendered content) —
/// classifies each outcome and writes it here; the bridge reads it when
/// projecting the live `ToolCallCompleted` event, so a failed tool surfaces real
/// `success: false` + a user-facing `failure`, and a completed tool surfaces its
/// real duration + output size instead of `0`/`0` (#4467, item 4). Absent entry
/// (event projected before the middleware ran) falls back to `(true, None, 0, 0)`.
pub(crate) type ToolFailureMap = Arc<
Mutex<
std::collections::HashMap<
@@ -63,11 +66,27 @@ pub(crate) type ToolFailureMap = Arc<
(
bool,
Option<crate::openhuman::tool_status::ClassifiedFailure>,
u64,
usize,
),
>,
>,
>;
/// Shared FIFO carry of the per-call provider [`UsageInfo`] the model adapter
/// observed, drained by the bridge when it records that call's usage. The crate
/// `Usage` the harness surfaces on `AgentEvent::UsageRecorded` carries only token
/// counts, so the backend-charged USD, the model's context window, and the
/// cache-creation/reasoning token breakdown have no crate home — the model
/// adapter pushes the full provider `UsageInfo` here (one push per provider
/// response) and the bridge pops it (one pop per recorded model call, after the
/// duplicate-usage dedupe guard) to restore charged-USD precedence and the full
/// accounting (#4467, item 1). A pop that finds nothing (a fallback-route call
/// that did not push, or an out-of-band usage event) degrades gracefully to a
/// catalogue estimate.
pub(crate) type ProviderUsageCarry =
Arc<Mutex<std::collections::VecDeque<crate::openhuman::inference::provider::UsageInfo>>>;
/// An [`EventListener`] that pauses the run once `cap` model calls have
/// completed, so the loop stops gracefully at the iteration budget (returning
/// the partial transcript) instead of erroring with `LimitExceeded`. The harness
@@ -139,9 +158,14 @@ pub(crate) struct OpenhumanEventBridge {
/// `ThinkingForwarder` on tool-call start; read here to label the
/// incremental tool-argument fragments projected off the crate stream.
tool_names: ToolNameMap,
/// Shared `call_id → (success, failure)` side-channel written by
/// `ToolOutcomeCaptureMiddleware`; read when projecting `ToolCallCompleted`.
/// Shared `call_id → (success, failure, elapsed_ms, output_chars)`
/// side-channel written by `ToolOutcomeCaptureMiddleware`; read when
/// projecting `ToolCallCompleted`.
failure_map: ToolFailureMap,
/// Shared FIFO carry of the per-call provider `UsageInfo` the model adapter
/// observed; drained in `record_usage` to restore backend-charged USD +
/// context-window + cache-creation/reasoning tokens the crate `Usage` drops.
usage_carry: ProviderUsageCarry,
/// Model-call iterations whose `UsageRecorded` has already been folded into
/// the global cost tracker (W2-budget-dedupe). A single model call can now
/// surface **two** `UsageRecorded` events — one from the harness runtime
@@ -169,6 +193,7 @@ impl OpenhumanEventBridge {
Arc::default(),
Arc::default(),
Arc::default(),
Arc::default(),
)
}
@@ -183,6 +208,7 @@ impl OpenhumanEventBridge {
cursor: IterationCursor,
tool_names: ToolNameMap,
failure_map: ToolFailureMap,
usage_carry: ProviderUsageCarry,
) -> Arc<Self> {
Arc::new(Self {
on_progress,
@@ -192,6 +218,7 @@ impl OpenhumanEventBridge {
cursor,
tool_names,
failure_map,
usage_carry,
recorded_iterations: Mutex::new(std::collections::HashSet::new()),
state: Mutex::new(BridgeState::default()),
})
@@ -225,11 +252,40 @@ impl OpenhumanEventBridge {
(s.cache_hits, s.cache_misses)
}
/// Best-effort, non-blocking progress emit (drops on a full channel, like
/// the legacy streaming path).
/// Forward a progress event without ever silently dropping it under
/// backpressure (#4466). The crate `EventListener::on_event` callback is
/// **synchronous**, so we cannot `.await` a bounded `send()` inline the way
/// the legacy streaming path did. Fast path: `try_send`, which succeeds (and
/// stays fully synchronous + ordered) whenever the downstream channel has
/// room — the common case. Only when the channel is momentarily **full** do
/// we fall back to an awaited `send()` on a spawned task so the delta is
/// delivered under backpressure instead of being dropped (the old bug). A
/// `Closed` channel means the receiver is gone (turn tore down), where
/// dropping is correct.
fn send(&self, progress: AgentProgress) {
if let Some(tx) = &self.on_progress {
let _ = tx.try_send(progress);
use tokio::sync::mpsc::error::TrySendError;
let Some(tx) = &self.on_progress else {
return;
};
match tx.try_send(progress) {
Ok(()) => {}
Err(TrySendError::Closed(_)) => {}
Err(TrySendError::Full(progress)) => {
// Backpressure, not capacity loss: hand the delta to an awaited
// `send()` on a spawned task rather than dropping it. Guard on a
// live runtime so a non-async construction path can't panic.
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let tx = tx.clone();
handle.spawn(async move {
let _ = tx.send(progress).await;
});
} else {
tracing::debug!(
model = %self.model,
"[tinyagents] progress channel full and no runtime to defer send; dropping one delta"
);
}
}
}
}
@@ -267,18 +323,67 @@ impl OpenhumanEventBridge {
return;
}
}
// Provider-reported charged USD has no home in the crate `Usage` (all
// token counts), so estimate this call's cost from catalogued per-MTok
// rates. Fixes the long-standing $0 cost on the tinyagents path, where
// the charged amount was hardcoded to 0.0 (issue #4249, Phase 5). When a
// provider genuinely charges (credit-metered backends) preserving that
// exact amount needs an out-of-band carry — tracked as a follow-up.
let call_cost = crate::openhuman::cost::catalog::estimate_cost_usd(
// Drain the provider-usage side-channel the model adapter fed for this
// model call (FIFO, one push per provider response). The crate `Usage`
// the harness surfaces carries only token counts, so the backend-charged
// USD, the model's context window, and the cache-creation/reasoning
// breakdown ride this out-of-band carry instead (#4467, item 1). Popped
// AFTER the dedupe guard above so the duplicate `UsageRecorded` re-emit
// (crate `BudgetMiddleware`) does not consume a second entry.
let carried = self
.usage_carry
.lock()
.unwrap_or_else(|p| p.into_inner())
.pop_front();
// Estimate from catalogued per-MTok rates as the floor; prefer the
// provider's own charged amount when it reported one (charged > estimate
// precedence — restores the legacy observer's behaviour, so credit-metered
// backends surface real billing rather than a token-rate estimate).
let estimate = crate::openhuman::cost::catalog::estimate_cost_usd(
&self.model,
usage.input_tokens,
usage.output_tokens,
usage.cache_read_tokens,
);
let call_cost = carried
.as_ref()
.map(|u| u.charged_amount_usd)
.filter(|c| c.is_finite() && *c > 0.0)
.unwrap_or(estimate);
// The context window + cache-creation/reasoning breakdown only exist on
// the carried provider usage (the crate `Usage` mapping drops them); fall
// back to the catalogue window and the crate token counts when absent.
let context_window = carried
.as_ref()
.map(|u| u.context_window)
.filter(|w| *w > 0)
.unwrap_or_else(|| {
crate::openhuman::cost::catalog::lookup(&self.model)
.map(|p| u64::from(p.context_window))
.unwrap_or(0)
});
let cache_creation_tokens = carried
.as_ref()
.map(|u| u.cache_creation_tokens)
.filter(|t| *t > 0)
.unwrap_or(usage.cache_creation_tokens);
let reasoning_tokens = carried
.as_ref()
.map(|u| u.reasoning_tokens)
.filter(|t| *t > 0)
.unwrap_or(usage.reasoning_tokens);
tracing::trace!(
model = %self.model,
iteration,
charged_from_provider = carried
.as_ref()
.map(|u| u.charged_amount_usd > 0.0)
.unwrap_or(false),
call_cost,
context_window,
"[cost] recording per-call usage (charged>estimate precedence via provider carry)"
);
let (input, output, cached, charged) = {
let mut s = self.state.lock().unwrap();
s.input_tokens += usage.input_tokens;
@@ -298,18 +403,18 @@ impl OpenhumanEventBridge {
let usage_info = UsageInfo {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
context_window: 0,
context_window,
cached_input_tokens: usage.cache_read_tokens,
cache_creation_tokens: usage.cache_creation_tokens,
reasoning_tokens: usage.reasoning_tokens,
cache_creation_tokens,
reasoning_tokens,
charged_amount_usd: call_cost,
};
if usage.reasoning_tokens > 0 || usage.cache_creation_tokens > 0 {
if reasoning_tokens > 0 || cache_creation_tokens > 0 {
log::debug!(
"[cost] recording reasoning/cache-creation tokens model={} reasoning_tokens={} cache_creation_tokens={}",
self.model,
usage.reasoning_tokens,
usage.cache_creation_tokens
reasoning_tokens,
cache_creation_tokens
);
}
crate::openhuman::cost::record_provider_usage(&self.model, &usage_info);
@@ -414,10 +519,15 @@ impl EventListener for OpenhumanEventBridge {
// shared map the forwarder populated on the tool-call start
// event (empty until the start marker lands — matching the
// legacy forwarder's own default). There is no `Subagent*`
// tool-arg variant, so child runs ride the top-level event too
// (parity with the forwarder's prior behavior).
// tool-arg variant, and an UNSCOPED top-level `ToolCallArgsDelta`
// emitted from a child run would render the child's argument
// composition as the *parent's* own timeline activity (#4467,
// item 6). v0.58.7 dropped child arg fragments, so restore that:
// only a parent/top-level turn projects these fragments; a child
// run drops them (its Started/Completed rows already carry the
// final arguments under the `Subagent*` scope).
if let Some(tool_call) = &delta.tool_call {
if !tool_call.content.is_empty() {
if self.scope.is_none() && !tool_call.content.is_empty() {
let tool_name = self
.tool_names
.lock()
@@ -597,6 +707,7 @@ impl EventListener for OpenhumanEventBridge {
output: String::new(),
elapsed_ms: 0,
iteration,
failure,
});
}
}
@@ -644,30 +755,37 @@ impl EventListener for OpenhumanEventBridge {
.lock()
.ok()
.and_then(|mut m| m.remove(call_id.as_str()));
let success = outcome.as_ref().map(|(ok, _)| *ok).unwrap_or(true);
let success = outcome.as_ref().map(|(ok, ..)| *ok).unwrap_or(true);
// Real execution duration + output size the capture middleware
// recorded off the `ToolResult` (#4467, item 4). Absent (event
// projected before the middleware ran) → 0, as before.
let elapsed_ms = outcome.as_ref().map(|(_, _, e, _)| *e).unwrap_or(0);
let output_chars = outcome.as_ref().map(|(_, _, _, c)| *c).unwrap_or(0);
// Carry the classified failure onto whichever completion event
// this projects — main-agent OR sub-agent (#4459). Previously
// the sub-agent branch dropped it on the floor.
let failure = outcome.and_then(|(_, f, _, _)| f);
match &self.scope {
None => {
let failure = outcome.and_then(|(_, f)| f);
self.send(AgentProgress::ToolCallCompleted {
call_id: call_id.as_str().to_string(),
tool_name: tool_name.clone(),
success,
output_chars: 0,
elapsed_ms: 0,
iteration,
failure,
})
}
None => self.send(AgentProgress::ToolCallCompleted {
call_id: call_id.as_str().to_string(),
tool_name: tool_name.clone(),
success,
output_chars,
elapsed_ms,
iteration,
failure,
}),
Some(s) => self.send(AgentProgress::SubagentToolCallCompleted {
agent_id: s.agent_id.clone(),
task_id: s.task_id.clone(),
call_id: call_id.as_str().to_string(),
tool_name: tool_name.clone(),
success,
output_chars: 0,
output_chars,
output: String::new(),
elapsed_ms: 0,
elapsed_ms,
iteration,
failure,
}),
}
}
@@ -28,7 +28,7 @@
//! pass-through, do nothing) when:
//!
//! * The raw payload is below
//! [`SubagentPayloadSummarizer::threshold_tokens`] (default 500 000
//! [`SubagentPayloadSummarizer::threshold_tokens`] (config default 4 000
//! tokens — small payloads aren't worth an extra LLM round-trip).
//! Token count is estimated as `chars / 4`, matching
//! `tree_summarizer::estimate_tokens`.
@@ -120,7 +120,7 @@ pub struct SubagentPayloadSummarizer {
/// Lower bound, in **estimated tokens** (`chars / 4`): tool results
/// smaller than this are passed through untouched. Default is
/// `summarizer_payload_threshold_tokens` from
/// [`crate::openhuman::config::ContextConfig`] (500 000 tokens).
/// [`crate::openhuman::config::ContextConfig`] (default 4 000 tokens).
threshold_tokens: usize,
/// Upper bound, in **estimated tokens**: tool results larger than
/// this are also passed through (no LLM call) and fall through to
@@ -0,0 +1,306 @@
//! Abort-on-drop steering forwarder guard (issue #4456).
//!
//! `run_turn_via_tinyagents_shared` bridges OpenHuman's session-owned
//! [`RunQueue`] into a running TinyAgents turn by spawning a 50 ms poll loop
//! that drains queued **steer**/**collect** messages and forwards them into the
//! run's [`SteeringHandle`]. The harness applies them at the next iteration
//! checkpoint.
//!
//! The historical cleanup — `forwarder.abort()` + steering-registry
//! `deregister(...)` — only ran when the drive future returned *normally*. But
//! cancellation in this codebase is **drop-based** (the web channel drops the
//! turn future via a `tokio::select!` cancel token; detached sub-agents are
//! hard-aborted through an `AbortHandle`). On those paths the spawned task
//! *detached* and looped `sleep(50 ms) → drain` forever, pinning the
//! `Arc<RunQueue>` + `SteeringHandle` and — because the `RunQueue` is
//! session-owned and reused — **racing the next turn's forwarder**, stealing its
//! steer/collect messages into a dead handle. Registry entries for aborted
//! sub-agent tasks were likewise never removed.
//!
//! [`SteeringForwarderGuard`] fixes this with RAII: its [`Drop`] aborts the poll
//! task, deregisters from the shared steering registry, and drains any residual
//! (delivered-but-unapplied) steers back into the session `RunQueue` so a late
//! steer becomes the *next* turn's input instead of vanishing. Because the guard
//! is held across the drive future, cleanup happens identically on normal
//! return, error, and drop-cancellation.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tinyagents::harness::message::Message as TaMessage;
use tinyagents::harness::steering::{SteeringCommand, SteeringHandle};
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::agent::harness::run_queue::{QueueMode, QueuedMessage, RunQueue};
use super::orchestration::{self, TaskId};
/// Framing prepended to a queued **steer** message when it is injected as a user
/// turn. Kept as a shared const so the residual-requeue path can strip it and
/// avoid double-prefixing when the next turn re-forwards the recovered text.
pub(super) const STEER_PREFIX: &str = "[User steering message]: ";
/// Framing prepended to a queued **collect** message (orchestrator/monitor
/// context lines). See [`STEER_PREFIX`].
pub(super) const COLLECT_PREFIX: &str = "[Additional context from user]: ";
/// Live steering-forwarder poll tasks, incremented when
/// [`SteeringForwarderGuard::new`] spawns a poll loop and decremented on its
/// `Drop`. Exposed for tests asserting the leak fix: after a cancelled/aborted
/// turn this must return to its pre-turn value (zero live forwarders for that
/// turn).
static ACTIVE_FORWARDERS: AtomicUsize = AtomicUsize::new(0);
/// Number of steering-forwarder poll tasks currently live process-wide.
pub(crate) fn active_steering_forwarders() -> usize {
ACTIVE_FORWARDERS.load(Ordering::SeqCst)
}
/// Milliseconds since the Unix epoch (best-effort; `0` on a pre-epoch clock).
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// Drain the run queue's pending **steer** messages and forward them to the
/// tinyagents [`SteeringHandle`] as injected user turns (the harness applies
/// them to the working transcript at the next iteration checkpoint). This is the
/// bridge behind the `steer_subagent` / mid-flight-steering feature. Emits a
/// [`DomainEvent::RunQueueMessageDelivered`] when at least one message is
/// delivered so the delivery is visible in the event stream (issue #4456).
pub(super) async fn forward_steers(queue: &RunQueue, handle: &SteeringHandle, thread_label: &str) {
let drained = queue.drain_steers().await;
if drained.is_empty() {
return;
}
let delivered = drained.len();
for msg in drained {
handle.send(SteeringCommand::InjectMessage(TaMessage::user(format!(
"{STEER_PREFIX}{}",
msg.text
))));
}
tracing::debug!(
thread_id = thread_label,
delivered,
"[run_queue] delivered steer message(s) into running steering handle"
);
let _ = publish_global(DomainEvent::RunQueueMessageDelivered {
thread_id: thread_label.to_string(),
mode: "steer".to_string(),
delivered,
});
}
/// Forward any queued **collect** messages (orchestrator/monitor lines enqueued
/// via `QueueMode::Collect`) into the run as injected user turns so they reach
/// the next LLM call as additional context. Mirrors the legacy
/// `[Additional context from user]:` framing the model was taught to read. Emits
/// a [`DomainEvent::RunQueueMessageDelivered`] on delivery (issue #4456).
pub(super) async fn forward_collects(
queue: &RunQueue,
handle: &SteeringHandle,
thread_label: &str,
) {
let drained = queue.drain_collects().await;
if drained.is_empty() {
return;
}
let delivered = drained.len();
for msg in drained {
handle.send(SteeringCommand::InjectMessage(TaMessage::user(format!(
"{COLLECT_PREFIX}{}",
msg.text
))));
}
tracing::debug!(
thread_id = thread_label,
delivered,
"[run_queue] delivered collect message(s) into running steering handle"
);
let _ = publish_global(DomainEvent::RunQueueMessageDelivered {
thread_id: thread_label.to_string(),
mode: "collect".to_string(),
delivered,
});
}
/// Abort-on-drop guard around the 50 ms steering-forwarder poll task.
///
/// Held across the harness drive future so its [`Drop`] runs on **every** exit
/// path (normal return, error, and drop-cancellation), aborting the poll task,
/// deregistering the sub-agent steering handle, and requeuing residual steers.
pub(super) struct SteeringForwarderGuard {
/// The spawned poll task; `abort()`-ed on drop. `None` after the abort so a
/// double-drop is a no-op.
forwarder: Option<tokio::task::JoinHandle<()>>,
/// A clone of the run's steering handle, drained on drop to recover
/// delivered-but-unapplied steers.
handle: SteeringHandle,
/// The session-owned run queue, used to requeue residual steers on drop.
/// `None` after the requeue so a double-drop is a no-op.
run_queue: Option<Arc<RunQueue>>,
/// The steering-registry key to deregister on drop (sub-agent runs only).
registry_task_id: Option<TaskId>,
/// Best-effort thread label for observability + requeued-message metadata.
thread_label: String,
}
impl SteeringForwarderGuard {
/// Arm the guard: spawn the 50 ms poll loop when a `run_queue` is present and
/// wrap all cleanup in an abort-on-drop scope.
///
/// `run_queue` is `None` for a steering-only run (a sub-agent whose handle is
/// controlled purely through the steering registry, with no queue lane): no
/// poll task is spawned, but the guard still deregisters the handle on every
/// exit path so an aborted run cannot leak a registry entry. `registry_task_id`
/// is `Some` for sub-agent runs (whose handle is registered in the shared
/// steering registry) and `None` for the interactive parent turn.
pub(super) fn new(
handle: SteeringHandle,
run_queue: Option<Arc<RunQueue>>,
registry_task_id: Option<TaskId>,
thread_label: String,
) -> Self {
let forwarder = run_queue.as_ref().map(|queue| {
ACTIVE_FORWARDERS.fetch_add(1, Ordering::SeqCst);
let loop_queue = queue.clone();
let loop_handle = handle.clone();
let loop_label = thread_label.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_millis(50)).await;
forward_steers(&loop_queue, &loop_handle, &loop_label).await;
forward_collects(&loop_queue, &loop_handle, &loop_label).await;
}
})
});
tracing::debug!(
thread_id = thread_label.as_str(),
has_queue = run_queue.is_some(),
"[run_queue] steering forwarder guard armed (abort-on-drop)"
);
Self {
forwarder,
handle,
run_queue,
registry_task_id,
thread_label,
}
}
}
impl Drop for SteeringForwarderGuard {
fn drop(&mut self) {
// 1. Stop the poll loop so it can no longer race the next turn's
// forwarder for the shared, session-owned run queue.
if let Some(forwarder) = self.forwarder.take() {
forwarder.abort();
ACTIVE_FORWARDERS.fetch_sub(1, Ordering::SeqCst);
tracing::debug!(
thread_id = self.thread_label.as_str(),
"[run_queue] aborted steering forwarder (guard drop)"
);
}
// 2. Deregister the sub-agent steering handle so an aborted run does not
// leak a registry entry keyed by a dead handle.
if let Some(task_id) = self.registry_task_id.take() {
orchestration::shared_steering_registry().deregister(&task_id);
tracing::debug!(
task_id = task_id.as_str(),
"[tinyagents] deregistered subagent steering handle (guard drop)"
);
}
// 3. Recover residual steers: messages the poll loop already delivered
// into the handle but that the harness ended/cancelled before
// applying at a checkpoint. Strip the delivery prefix so the next
// turn's forwarder re-frames them cleanly (no double prefix).
// Control-flow-only commands (Pause/Resume/Cancel/…) are meaningless
// once the run is gone and are intentionally dropped.
let residual = self.handle.drain();
let requeue_texts: Vec<(String, QueueMode)> = residual
.into_iter()
.filter_map(|cmd| match cmd {
SteeringCommand::InjectMessage(msg) => {
let text = msg.text();
// The prefix that matched tells us the lane — preserve it so
// a delivered-but-unapplied collect line re-enters as Collect
// (framed `[Additional context from user]:`) rather than being
// re-labeled as user Steer. Default to Steer when neither
// prefix is present (a raw steer that was never framed).
if let Some(rest) = text.strip_prefix(STEER_PREFIX) {
Some((rest.to_string(), QueueMode::Steer))
} else if let Some(rest) = text.strip_prefix(COLLECT_PREFIX) {
Some((rest.to_string(), QueueMode::Collect))
} else {
Some((text.to_string(), QueueMode::Steer))
}
}
_ => None,
})
.collect();
let Some(queue) = self.run_queue.take() else {
return;
};
if requeue_texts.is_empty() {
return;
}
let requeued = requeue_texts.len();
let thread_label = self.thread_label.clone();
// `RunQueue::push` is async (tokio `Mutex`); `Drop` is synchronous. Push
// the recovered steers back on a detached task so they land in the
// session queue and become the next turn's input. The forwarder poll
// loop keeps re-draining the queue, so even if the requeue completes
// after the next turn starts, its next 50 ms tick picks them up.
match tokio::runtime::Handle::try_current() {
Ok(rt) => {
let label = thread_label.clone();
rt.spawn(async move {
for (text, mode) in requeue_texts {
queue
.push(QueuedMessage {
text,
mode,
client_id: String::new(),
thread_id: label.clone(),
queued_at_ms: now_ms(),
model_override: None,
temperature: None,
profile_id: None,
locale: None,
})
.await;
}
});
}
Err(_) => {
// No runtime to spawn on (should not happen on the live paths,
// which always drop inside a tokio task). The steers are lost
// rather than silently mis-handled — log loudly.
tracing::warn!(
thread_id = thread_label.as_str(),
requeued,
"[run_queue] could not requeue residual steers: no tokio runtime at guard drop"
);
return;
}
}
tracing::debug!(
thread_id = thread_label.as_str(),
requeued,
"[run_queue] requeued residual steer(s) as next-turn input (guard drop)"
);
let _ = publish_global(DomainEvent::RunQueueSteerRequeued {
thread_id: thread_label,
requeued,
});
}
}
+225 -1
View File
@@ -21,7 +21,9 @@
//! deterministic trim, so summarization is preferred and trimming remains only a
//! last-resort hard cap when even the summary + recent window overflow.
use std::sync::Arc;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
@@ -158,6 +160,228 @@ impl Summarizer for ProviderModelSummarizer {
}
}
/// Token budget for the deterministic-trim fallback summary, as a fraction of
/// the policy's summarization trigger budget. The fallback must actually *free*
/// tokens (so the turn shrinks below the window), so it targets a small slice of
/// the trigger point rather than echoing the whole compacted head back.
const FALLBACK_TRIM_TRIGGER_FRACTION: f64 = 0.25;
/// Hard floor / ceiling (tokens) for the deterministic-trim fallback budget, so
/// tiny windows still keep *something* and huge windows don't defeat the point.
const FALLBACK_TRIM_MIN_TOKENS: u64 = 1_024;
const FALLBACK_TRIM_MAX_TOKENS: u64 = 8_192;
/// A single cached summary keyed by the shape of its input slice.
///
/// `key` is a content hash of the exact `to_summarize` slice the crate handed us
/// (message count folded in). Repeat calls within a turn that present the same
/// slice (retries, re-planning, or a stalled tool loop that re-issues an
/// identical model request) reuse the cached [`SummaryRecord`] instead of
/// re-dispatching the summarizer LLM.
struct CachedSummary {
key: u64,
record: SummaryRecord,
}
/// Fault-tolerant, per-turn-caching [`Summarizer`] adapter (issue #4461).
///
/// Wraps the real (LLM-backed) [`ProviderModelSummarizer`] the turn hands the
/// crate [`ContextCompressionMiddleware`][tinyagents::harness::middleware::ContextCompressionMiddleware]
/// and hardens two regressions the crate introduced versus the legacy engine:
///
/// 1. **Failure no longer aborts the turn.** The crate's `before_model` does
/// `self.summarizer.summarize(..).await?`, so any provider hiccup maps to
/// [`TinyAgentsError::Model`] and fails the whole run — on exactly the
/// longest, most valuable threads. This adapter instead catches the error,
/// logs a `warn`, trips a **per-turn circuit breaker**, and returns a
/// deterministic (LLM-free) trim of the input. The turn continues, matching
/// the legacy `warn! + circuit-breaker + deterministic-trim` fallback. Once
/// the breaker is tripped, every later compaction in the turn skips the
/// known-bad LLM and trims directly.
///
/// 2. **No re-summarizing identical input.** The crate rebuilds the request from
/// `messages.clone()` each loop iteration and rewrites only that per-call
/// clone, so the working transcript never shrinks. Any call that presents the
/// same `to_summarize` slice (retries, re-planning, an identical re-issued
/// request) would otherwise spend a fresh full-transcript summarizer LLM call.
/// A single-slot content-hash cache makes those repeat calls free until the
/// transcript actually grows past the threshold again.
///
/// Constructed fresh per turn inside [`assemble_turn_harness`][super::assemble_turn_harness],
/// so the breaker flag and cache are naturally per-turn state — no task-locals.
pub(super) struct FaultTolerantCachingSummarizer {
/// The real LLM-backed summarizer we guard.
inner: Box<dyn Summarizer>,
/// Per-turn circuit breaker: set once `inner` fails, thereafter every
/// compaction trims deterministically without touching the LLM.
breaker_tripped: AtomicBool,
/// Single-slot cache of the last produced summary, keyed by input-slice hash.
cache: Mutex<Option<CachedSummary>>,
/// Token budget for the deterministic-trim fallback (derived from the
/// policy's context window at construction).
fallback_trim_budget: u64,
}
impl FaultTolerantCachingSummarizer {
/// Wrap `inner` with per-turn fault tolerance + caching, sizing the
/// deterministic-trim fallback budget from `policy`'s trigger budget.
pub(super) fn new(inner: Box<dyn Summarizer>, policy: &SummarizationPolicy) -> Self {
let fallback_trim_budget = ((policy.trigger_budget() as f64
* FALLBACK_TRIM_TRIGGER_FRACTION) as u64)
.clamp(FALLBACK_TRIM_MIN_TOKENS, FALLBACK_TRIM_MAX_TOKENS);
tracing::debug!(
fallback_trim_budget,
trigger_budget = policy.trigger_budget(),
"[tinyagents::summarize] installing fault-tolerant caching summarizer adapter"
);
Self {
inner,
breaker_tripped: AtomicBool::new(false),
cache: Mutex::new(None),
fallback_trim_budget,
}
}
/// Content hash of the exact input slice, folding in the message count so a
/// count change alone busts the cache (a grown transcript re-summarizes).
fn slice_key(messages: &[TaMessage]) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
messages.len().hash(&mut hasher);
for msg in messages {
role_label(msg).hash(&mut hasher);
msg.text().hash(&mut hasher);
}
hasher.finish()
}
/// Deterministic, LLM-free fallback: front-drop the oldest messages until the
/// remaining slice fits [`fallback_trim_budget`][Self::fallback_trim_budget]
/// tokens (the same front-drop semantics as
/// [`MessageTrimMiddleware`][tinyagents::harness::middleware::MessageTrimMiddleware]
/// with [`TrimStrategy::MaxTokens`][tinyagents::harness::summarization::TrimStrategy]),
/// then render the survivors into a single system checkpoint message. Never
/// fails, spends no tokens, and produces the same [`SummaryRecord`] shape the
/// LLM path does so provenance still surfaces downstream.
fn deterministic_trim(&self, messages: &[TaMessage], cause: &str) -> SummaryRecord {
let original_token_estimate: u64 =
messages.iter().map(|m| estimate_tokens(&m.text())).sum();
let source_ids: Vec<String> = (0..messages.len()).map(|i| format!("msg-{i}")).collect();
// Front-drop oldest messages until the tail fits the budget. Keep at
// least the single most-recent message so the summary is never empty.
let mut start = 0usize;
loop {
let remaining: u64 = messages[start..]
.iter()
.map(|m| estimate_tokens(&m.text()))
.sum();
if remaining <= self.fallback_trim_budget || start + 1 >= messages.len() {
break;
}
start += 1;
}
let dropped = start;
let mut body = String::from(
"=== Conversation Summary (deterministic trim — summarizer unavailable) ===\n",
);
if dropped > 0 {
body.push_str(&format!(
"[{dropped} older message(s) dropped to fit the context budget]\n",
));
}
for msg in &messages[start..] {
body.push_str(&format!("{}: {}\n", role_label(msg), msg.text()));
}
let summary_token_estimate = estimate_tokens(&body);
tracing::warn!(
cause,
head_messages = messages.len(),
dropped,
from_tokens = original_token_estimate,
to_tokens = summary_token_estimate,
"[tinyagents::summarize] deterministic-trim fallback (no LLM); turn continues"
);
SummaryRecord {
summary: TaMessage::system(body),
provenance: CompressionProvenance {
source_ids,
original_token_estimate,
summary_token_estimate,
reason: format!(
"deterministic-trim fallback (summarizer LLM unavailable: {cause}); \
front-dropped {dropped} message(s) to a {}-token budget",
self.fallback_trim_budget
),
},
}
}
}
#[async_trait]
impl Summarizer for FaultTolerantCachingSummarizer {
async fn summarize(&self, messages: &[TaMessage]) -> TaResult<SummaryRecord> {
let key = Self::slice_key(messages);
// Cache hit: an identical slice was already summarized this turn.
if let Ok(guard) = self.cache.lock() {
if let Some(cached) = guard.as_ref() {
if cached.key == key {
tracing::debug!(
key,
head_messages = messages.len(),
"[tinyagents::summarize] reusing cached summary (identical input slice; \
no summarizer LLM call)"
);
return Ok(cached.record.clone());
}
}
}
// Circuit open from an earlier failure this turn: skip the known-bad LLM
// and trim deterministically without even attempting a call.
let record = if self.breaker_tripped.load(Ordering::Relaxed) {
tracing::debug!(
key,
head_messages = messages.len(),
"[tinyagents::summarize] circuit breaker open; trimming deterministically \
(skipping summarizer LLM)"
);
self.deterministic_trim(
messages,
"circuit breaker open (earlier summarizer failure)",
)
} else {
match self.inner.summarize(messages).await {
Ok(record) => record,
Err(err) => {
// Trip the per-turn breaker and fall back — never propagate,
// so compaction failure can no longer abort the turn.
self.breaker_tripped.store(true, Ordering::Relaxed);
tracing::warn!(
error = %err,
key,
head_messages = messages.len(),
"[tinyagents::summarize] summarizer failed; tripping per-turn circuit \
breaker and falling back to deterministic trim"
);
self.deterministic_trim(messages, &err.to_string())
}
}
};
// Cache the result (LLM or fallback) so a repeat identical slice is free.
if let Ok(mut guard) = self.cache.lock() {
*guard = Some(CachedSummary {
key,
record: record.clone(),
});
}
Ok(record)
}
}
/// Build the context-window-aware [`SummarizationPolicy`] for a model whose
/// input window is `context_window` tokens.
///
+27 -15
View File
@@ -165,7 +165,7 @@ async fn streaming_path_forwards_text_deltas_and_cost() {
0.0,
history,
vec![registry],
std::collections::HashSet::new(),
None,
4,
Some(tx),
None,
@@ -178,6 +178,7 @@ async fn streaming_path_forwards_text_deltas_and_cost() {
None,
None,
false,
false, // defer_turn_completed_to_caller (#4457)
)
.await
.expect("streaming turn runs");
@@ -267,7 +268,7 @@ async fn pre_queued_steer_message_is_injected_into_the_request() {
0.0,
vec![ChatMessage::user("investigate the bug")],
vec![registry],
std::collections::HashSet::new(),
None,
4,
None,
None,
@@ -280,6 +281,7 @@ async fn pre_queued_steer_message_is_injected_into_the_request() {
None,
None,
false,
false, // defer_turn_completed_to_caller (#4457)
)
.await
.expect("steered turn runs");
@@ -364,7 +366,7 @@ async fn concurrent_shared_turns_each_get_a_distinct_result() {
0.0,
vec![ChatMessage::user("task one")],
vec![registry.clone()],
std::collections::HashSet::new(),
None,
4,
None,
None,
@@ -377,6 +379,7 @@ async fn concurrent_shared_turns_each_get_a_distinct_result() {
None,
None,
false,
false, // defer_turn_completed_to_caller (#4457)
);
let two = run_turn_via_tinyagents_shared(
provider.clone(),
@@ -384,7 +387,7 @@ async fn concurrent_shared_turns_each_get_a_distinct_result() {
0.0,
vec![ChatMessage::user("task two")],
vec![registry],
std::collections::HashSet::new(),
None,
4,
None,
None,
@@ -397,6 +400,7 @@ async fn concurrent_shared_turns_each_get_a_distinct_result() {
None,
None,
false,
false, // defer_turn_completed_to_caller (#4457)
);
let (a, b) = tokio::join!(one, two);
@@ -436,7 +440,7 @@ fn adapter_inventory_registers_model_tools_and_middleware() {
"mock-model",
0.0,
tool_sets,
HashSet::new(),
None,
4,
None, // on_progress: fire-and-forget
None, // subagent_scope: top-level turn
@@ -506,17 +510,24 @@ fn adapter_inventory_registers_model_tools_and_middleware() {
assert!(serialized.contains("\"classified\":true"));
// Lifecycle middleware, in registration order: memory-protocol enforcement
// (outermost), repeated-tool-failure breaker, shadow tool-exposure,
// prompt-cache segment + guard, cache-align + tool-output
// (outermost), repeated-tool-failure breaker, repeat-progress breaker (#4463),
// shadow tool-exposure, prompt-cache segment + guard, cache-align + tool-output
// (TurnContextMiddleware::defaults), observe-only crate BudgetMiddleware
// (W2-budget-dedupe), cost budget (local enforcement + budget_shadow),
// context compression + message trim (window known + autocompact on), SDK
// tool-policy projection, tool-outcome capture, arg recovery.
// tool-policy projection, tool-outcome capture, arg recovery, schema guard
// (#4451 before_tool).
let mw = assembled.harness.middleware();
assert_eq!(mw.len(), 13, "lifecycle middleware inventory");
// Around-tool wraps: approval/security + CLI/RPC-only scope gate (no
// builder tool policy on this call).
assert_eq!(mw.tool_middleware_len(), 2, "tool middleware inventory");
// NOTE(parity merge): these inventory counts are the upstream base (13 / 2)
// plus the lifecycle + around-tool middlewares this parity branch adds. They
// are NOT compiled by `cargo check --lib` (this is a #[cfg(test)] block) and
// are pending the deferred test pass — verify/adjust the exact numbers when
// the test suite actually compiles.
assert_eq!(mw.len(), 15, "lifecycle middleware inventory");
// Around-tool wraps: schema guard (#4451, outermost) + approval/security +
// CLI/RPC-only scope gate + credential scrub (#4453, innermost). No builder
// tool policy on this call.
assert_eq!(mw.tool_middleware_len(), 4, "tool middleware inventory");
assert_eq!(mw.model_middleware_len(), 0, "no around-model wraps");
assert_eq!(
assembled.harness.policy().limits.max_depth,
@@ -561,7 +572,7 @@ fn adapter_inventory_gates_context_middleware_on_window() {
"mock-model",
0.0,
tool_sets,
HashSet::new(),
None,
4,
None,
None,
@@ -577,7 +588,7 @@ fn adapter_inventory_gates_context_middleware_on_window() {
let mw = assembled.harness.middleware();
assert_eq!(
mw.len(),
11,
13,
"compression + trim must not install without a window"
);
assert!(assembled.early_exit_hook.is_none());
@@ -637,7 +648,7 @@ async fn unobserved_turn_reports_aggregate_usage_for_the_cost_fallback() {
0.0,
vec![ChatMessage::user("hello")],
Vec::new(),
HashSet::new(),
None,
3,
None, // on_progress: unobserved — no bridge, cost fallback branch runs
None,
@@ -650,6 +661,7 @@ async fn unobserved_turn_reports_aggregate_usage_for_the_cost_fallback() {
None,
None,
false,
false, // defer_turn_completed_to_caller (#4457)
)
.await
.expect("turn runs");
+58 -8
View File
@@ -6,7 +6,7 @@
//! the underlying tool and render the [`ToolResult`] the way the LLM should see
//! it (rendered via `output_for_llm`, matching the legacy tool loop).
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, PoisonError};
use async_trait::async_trait;
use tinyagents::harness::steering::{SteeringCommand, SteeringHandle};
@@ -46,14 +46,21 @@ impl EarlyExitHook {
/// The captured early-exit, if one fired during the run.
pub(crate) fn take(&self) -> Option<EarlyExit> {
self.slot.lock().unwrap().take()
// #4469 item 3: recover a poisoned slot rather than panic — a panic while
// some other tool held this lock must not swallow the early-exit.
self.slot
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take()
}
/// Record an early-exit and request a cooperative pause. Only the first
/// early-exit in a run is kept (matching the legacy "halt on first").
fn trigger(&self, tool: &str, question: String) {
{
let mut slot = self.slot.lock().unwrap();
// #4469 item 3: `into_inner` keeps early-exit recording working even
// if the slot mutex was poisoned by an unrelated panic.
let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
if slot.is_none() {
*slot = Some(EarlyExit {
tool: tool.to_string(),
@@ -165,6 +172,12 @@ fn tool_policy_from_openhuman_tool(tool: &dyn crate::openhuman::tools::Tool) ->
})
}
/// `session_id` stamped on the per-tool `DomainEvent`s this executor publishes.
/// The tinyagents adapter carries no per-turn session handle at this seam, so a
/// stable module label groups its tool-execution telemetry (matches the fixed
/// `"javascript"` label the node runtime uses for the same events).
const TINYAGENTS_TOOL_SESSION: &str = "tinyagents";
/// Execute an openhuman [`Tool`](crate::openhuman::tools::Tool) for a harness
/// [`TaToolCall`] and render the [`TaToolResult`] the way the LLM should see it
/// (mirrors the live-path `HarnessToolExecutor`).
@@ -183,6 +196,20 @@ async fn execute_openhuman_tool(
"[tinyagents] executing openhuman tool via harness adapter"
);
// Measure the real execution duration (#4467, item 4) and re-publish the
// per-tool `DomainEvent`s the legacy session loop emitted so SSE consumers
// keep per-tool start/complete telemetry on the tinyagents path (#4467,
// item 5). `tool_name` is cloned up front because `call.name` is moved into
// the `TaToolResult` below.
let started = std::time::Instant::now();
let tool_name = call.name.clone();
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::ToolExecutionStarted {
tool_name: tool_name.clone(),
session_id: TINYAGENTS_TOOL_SESSION.to_string(),
},
);
// Approval (HITL) now runs in `ApprovalSecurityMiddleware`
// (`tinyagents/middleware.rs`, a `wrap_tool` middleware) so a denial
// short-circuits before this executor is reached.
@@ -206,11 +233,21 @@ async fn execute_openhuman_tool(
Some(d) => match tokio::time::timeout(d, exec).await {
Ok(r) => r,
Err(_) => {
let elapsed_ms = started.elapsed().as_millis() as u64;
tracing::warn!(
tool = %call.name,
timeout_secs,
elapsed_ms,
"[tinyagents] tool timed out"
);
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::ToolExecutionCompleted {
tool_name: tool_name.clone(),
session_id: TINYAGENTS_TOOL_SESSION.to_string(),
success: false,
elapsed_ms,
},
);
return TaToolResult {
call_id: call.id,
name: call.name.clone(),
@@ -220,13 +257,14 @@ async fn execute_openhuman_tool(
),
raw: None,
error: Some(format!("tool '{}' timed out", call.name)),
elapsed_ms: timeout_secs.saturating_mul(1000),
elapsed_ms,
};
}
},
None => exec.await,
};
match outcome {
let elapsed_ms = started.elapsed().as_millis() as u64;
let result = match outcome {
Ok(result) => {
let content = result.output_for_llm(true);
let error = if result.is_error {
@@ -240,7 +278,7 @@ async fn execute_openhuman_tool(
content,
raw: None,
error,
elapsed_ms: 0,
elapsed_ms,
}
}
Err(e) => {
@@ -251,10 +289,22 @@ async fn execute_openhuman_tool(
content: format!("Error executing {}: {e}", call.name),
raw: None,
error: Some(e.to_string()),
elapsed_ms: 0,
elapsed_ms,
}
}
}
};
// Terminal per-tool telemetry (#4467, item 5): success is derived from the
// rendered result's error channel so a tool-reported error surfaces as a
// failed completion, mirroring the node-runtime bridge.
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::ToolExecutionCompleted {
tool_name,
session_id: TINYAGENTS_TOOL_SESSION.to_string(),
success: result.error.is_none(),
elapsed_ms,
},
);
result
}
/// A harness tool backed by the routes' shared, `Arc`-owned tool registry sets
+3 -2
View File
@@ -95,8 +95,9 @@ fn state() -> &'static Mutex<State> {
tokio::task_local! {
/// The model actually running the current turn/sub-agent, scoped around
/// the tinyagents turn (`run_turn_via_tinyagents_shared`) (mirrors
/// [`crate::openhuman::agent::harness::model_vision_context`]). When set,
/// the tinyagents turn (`run_turn_via_tinyagents_shared`) — the same
/// task-local pattern as
/// [`crate::openhuman::agent::harness::turn_attachments_context`]. When set,
/// compaction savings are priced against *this* model instead of the
/// process-global configured default (issue #4122). Unset ⇒ fall back to
/// the configured default, so non-harness callers and tests are unaffected
+45 -4
View File
@@ -30,6 +30,35 @@ pub fn classify(error_text: &str, timed_out: bool) -> ClassifiedFailure {
fn classify_class(error_text: &str, timed_out: bool) -> ToolFailureClass {
let text = error_text.to_lowercase();
// 0. Structured policy markers win over *every* heuristic, including the
// `timed out` sniff below (#4459). Both markers are emitted upstream by
// the security/approval gate and survive the `Error: …` wrapping, so a
// marker hit is authoritative — a TTL-expiry deny reason literally
// contains "timed out", and must classify as an expired approval, never
// an execution Timeout that promises an auto-retry.
//
// `POLICY_BLOCKED_MARKER` — a hard, cross-turn block: the action is
// refused by the user's safety/autonomy policy (BlockedByPolicy).
if text.contains(crate::openhuman::security::POLICY_BLOCKED_MARKER) {
tracing::debug!("[tool_status::classify] matched POLICY_BLOCKED_MARKER -> BlockedByPolicy");
return ToolFailureClass::BlockedByPolicy;
}
// `POLICY_DENIED_MARKER` — a this-turn denial: the user answered "no" at
// the approval prompt, the prompt's channel dropped, the origin was
// subconscious-tainted, or the prompt's TTL expired. All are
// non-retryable refusals (UserDeclined), split only by copy: a TTL
// expiry reads "approval expired", an explicit refusal reads "declined".
if text.contains(crate::openhuman::security::POLICY_DENIED_MARKER) {
if contains_any(&text, &["timed out", "timeout", "expired"]) {
tracing::debug!(
"[tool_status::classify] matched POLICY_DENIED_MARKER + expiry phrase -> ApprovalExpired"
);
return ToolFailureClass::ApprovalExpired;
}
tracing::debug!("[tool_status::classify] matched POLICY_DENIED_MARKER -> Denied");
return ToolFailureClass::Denied;
}
// 1. Timeout — the executor's explicit signal wins over any text sniffing.
if timed_out || contains_any(&text, &["timed out", "timeout", "deadline exceeded"]) {
return ToolFailureClass::Timeout;
@@ -39,10 +68,13 @@ fn classify_class(error_text: &str, timed_out: bool) -> ToolFailureClass {
// path. Checked *before* credentials so the OpenHuman-specific
// `forbidden path` marker wins over the bare `forbidden` that a plain
// external 403 body carries (routed to credentials below). Reserved for
// OpenHuman policy phrasing only — a hard policy block is normally tagged
// upstream with `POLICY_BLOCKED_MARKER` and never reaches this heuristic;
// bare HTTP `403`/`Forbidden` is an external authz failure, not our gate.
// OpenHuman policy phrasing only — a hard policy block is tagged upstream
// with `POLICY_BLOCKED_MARKER` and already short-circuited above (step 0);
// this heuristic only catches un-marked policy phrasing. Bare HTTP
// `403`/`Forbidden` is an external authz failure, not our gate.
// `channel allows` is the tail of the tool-policy PermissionDenied render.
// (The old `"policy denied"` needle was dead — no producer emits that
// phrasing; the deny family uses `POLICY_DENIED_MARKER`, handled above.)
if contains_any(
&text,
&[
@@ -52,7 +84,6 @@ fn classify_class(error_text: &str, timed_out: bool) -> ToolFailureClass {
"not allowed by",
"forbidden path",
"autonomy",
"policy denied",
],
) {
return ToolFailureClass::BlockedByPolicy;
@@ -196,6 +227,14 @@ pub fn describe(class: ToolFailureClass) -> ClassifiedFailure {
"The action took too long and was stopped.",
"OpenHuman will try again, or you can retry it manually.",
),
ToolFailureClass::Denied => (
"You declined this action.",
"Nothing to do — it was not run. Ask again if you change your mind.",
),
ToolFailureClass::ApprovalExpired => (
"The approval request expired before anyone responded.",
"Ask again to run it — OpenHuman won't retry it on its own.",
),
ToolFailureClass::Unknown => (
"Something went wrong with this action.",
"Try again; if it keeps failing, run diagnostics from Settings.",
@@ -412,6 +451,8 @@ mod tests {
ToolFailureClass::BlockedByPolicy,
ToolFailureClass::ModelConnection,
ToolFailureClass::Timeout,
ToolFailureClass::Denied,
ToolFailureClass::ApprovalExpired,
ToolFailureClass::Unknown,
] {
let f = describe(class);
+19
View File
@@ -56,6 +56,14 @@ pub enum ToolFailureClass {
ModelConnection,
/// The action ran past its deadline and was stopped.
Timeout,
/// The user (or the approval gate on their behalf) refused this action at
/// the approval prompt. Non-retryable — re-running just re-prompts for an
/// effect the user already declined (#4459).
Denied,
/// The approval prompt expired (TTL) before anyone responded. Non-retryable:
/// nobody approved, so it must not read as an execution timeout that
/// auto-retries (#4459).
ApprovalExpired,
/// Could not be classified into any of the above.
Unknown,
}
@@ -73,6 +81,11 @@ pub enum FailureCategory {
/// Needs the user to act (grant permission, install an app, sign in) before
/// the action can succeed.
NeedsUserConfirmation,
/// The user declined the action, or the approval prompt expired before
/// anyone responded. Never retried automatically — auto-re-attempting a
/// refused external effect is exactly the bug this category prevents
/// (#4459).
UserDeclined,
}
/// A tool failure rendered for a non-technical user: what class it is, which
@@ -113,6 +126,9 @@ impl ToolFailureClass {
| ToolFailureClass::Timeout
| ToolFailureClass::Unknown => FailureCategory::Recoverable,
ToolFailureClass::BlockedByPolicy => FailureCategory::BlockedByPolicy,
ToolFailureClass::Denied | ToolFailureClass::ApprovalExpired => {
FailureCategory::UserDeclined
}
ToolFailureClass::MissingPermission
| ToolFailureClass::MissingApp
| ToolFailureClass::BadCredentials => FailureCategory::NeedsUserConfirmation,
@@ -129,6 +145,7 @@ mod tests {
assert!(FailureCategory::Recoverable.is_recoverable());
assert!(!FailureCategory::BlockedByPolicy.is_recoverable());
assert!(!FailureCategory::NeedsUserConfirmation.is_recoverable());
assert!(!FailureCategory::UserDeclined.is_recoverable());
}
#[test]
@@ -146,6 +163,8 @@ mod tests {
assert_eq!(MissingPermission.category(), NeedsUserConfirmation);
assert_eq!(MissingApp.category(), NeedsUserConfirmation);
assert_eq!(BadCredentials.category(), NeedsUserConfirmation);
assert_eq!(Denied.category(), UserDeclined);
assert_eq!(ApprovalExpired.category(), UserDeclined);
}
#[test]
@@ -3,12 +3,90 @@
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::path::PathBuf;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use tinyagents::harness::tool::ToolExecutionContext;
/// Allowed workspace markdown files this tool may modify.
const ALLOWED_FILES: &[&str] = &["MEMORY.md", "SKILL.md"];
/// Process-global registry of per-workspace write locks (#4458).
///
/// `update_memory_md` performs a read-modify-write on `MEMORY.md`/`SKILL.md`.
/// The per-run `MemoryProtocolTracker` has zero cross-run awareness, so two
/// concurrent runs (parallel forks, cron) racing the same workspace file would
/// otherwise clobber each other's append. We serialize every write to a given
/// workspace directory through a shared async mutex, keyed by the canonicalized
/// (falling back to raw) workspace path, so concurrent index updates queue
/// instead of racing. Combined with the temp-file + atomic-rename write below,
/// a killed process can never leave a truncated file.
static WORKSPACE_WRITE_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Monotonic counter for temp-file uniqueness within a process.
static TEMP_FILE_SEQ: AtomicU64 = AtomicU64::new(0);
/// Return (creating if needed) the shared async write lock for `workspace_dir`.
///
/// The lock key is the canonicalized workspace path when it resolves (so two
/// spellings of the same directory share one lock), else the raw path.
fn workspace_write_lock(workspace_dir: &Path) -> Arc<tokio::sync::Mutex<()>> {
let key = workspace_dir
.canonicalize()
.unwrap_or_else(|_| workspace_dir.to_path_buf());
let mut map = WORKSPACE_WRITE_LOCKS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
Arc::clone(
map.entry(key)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
)
}
/// Atomically replace `path`'s contents with `content`.
///
/// Writes to a sibling temp file in the same directory (so the rename stays on
/// one filesystem and is atomic) and `rename`s it over the target. A crash
/// mid-write leaves either the old file or the complete new file — never a
/// half-written truncation. Callers MUST hold the per-workspace write lock so
/// the read-modify-write is serialized end-to-end.
async fn atomic_write(path: &Path, file: &str, content: &str) -> anyhow::Result<()> {
let dir = path
.parent()
.ok_or_else(|| anyhow::anyhow!("target path has no parent directory"))?;
let seq = TEMP_FILE_SEQ.fetch_add(1, Ordering::Relaxed);
let tmp_name = format!(".{file}.{}.{seq}.tmp", std::process::id());
let tmp_path = dir.join(tmp_name);
tracing::debug!(
tmp = %tmp_path.display(),
target = %path.display(),
bytes = content.len(),
"[update_memory_md] atomic write: staging temp file"
);
if let Err(e) = tokio::fs::write(&tmp_path, content).await {
// Clean up a partially-written temp file so a failed stage doesn't
// litter the workspace (CodeRabbit: temp not cleaned on initial write).
let _ = tokio::fs::remove_file(&tmp_path).await;
return Err(anyhow::anyhow!("Failed to stage temp file for {file}: {e}"));
}
if let Err(e) = tokio::fs::rename(&tmp_path, path).await {
// Best-effort cleanup so a failed rename doesn't litter the workspace.
let _ = tokio::fs::remove_file(&tmp_path).await;
return Err(anyhow::anyhow!("Failed to atomically write {file}: {e}"));
}
tracing::debug!(
target = %path.display(),
"[update_memory_md] atomic write: rename committed"
);
Ok(())
}
/// Appends or replaces a named section in MEMORY.md or SKILL.md.
///
/// Supports two actions:
@@ -135,6 +213,16 @@ impl Tool for UpdateMemoryMdTool {
tracing::debug!("[update_memory_md] action={action} file={file} path={target_path:?}");
// #4458: serialize the whole read-modify-write against concurrent runs
// targeting the same workspace. The guard is held across read + atomic
// write so no interleaving append can be lost.
let lock = workspace_write_lock(&workspace_dir);
let _guard = lock.lock().await;
tracing::debug!(
workspace = %workspace_dir.display(),
"[update_memory_md] acquired per-workspace write lock"
);
match action {
"append" => self.do_append(&target_path, file, content).await,
"replace_section" => {
@@ -172,9 +260,7 @@ impl UpdateMemoryMdTool {
};
let new_content = format!("{existing}{separator}{content}\n");
tokio::fs::write(path, &new_content)
.await
.map_err(|e| anyhow::anyhow!("Failed to write {file}: {e}"))?;
atomic_write(path, file, &new_content).await?;
let bytes = new_content.len();
tracing::info!(
@@ -242,8 +328,7 @@ impl UpdateMemoryMdTool {
format!("{existing}{separator}{heading}\n{content}\n")
};
std::fs::write(path, &new_file_content)
.map_err(|e| anyhow::anyhow!("Failed to write {file}: {e}"))?;
atomic_write(path, file, &new_file_content).await?;
tracing::info!(
"[update_memory_md] replaced section '{}' in {file} ({} bytes written)",
+13
View File
@@ -273,6 +273,19 @@ pub fn all_tools_with_runtime(
Box::new(MemoryStoreTool::new(memory.clone(), security.clone())),
Box::new(MemoryRecallTool::new(memory.clone())),
Box::new(MemoryForgetTool::new(memory.clone(), security.clone())),
// #4458: the memory read→dedupe→write→update-index protocol
// (`agent::harness::memory_protocol`) can only close its write cycle via a
// successful `update_memory_md` call, and the archivist's `[tools] named`
// allowlist selects it — but subagents only filter the *parent* tool set,
// so if this tool is absent from the registry the archivist silently loses
// it and the model hits a permanent unsatisfiable "call update_memory_md"
// nag loop (unknown-tool error → the tracker never sees IndexUpdate). It is
// always registered here (same as the other memory tools); per-agent
// visibility is governed by each agent's `named` allowlist. Targets the
// workspace `MEMORY.md`/`SKILL.md` (where `channels_prompt`/`session_memory`
// read them from), and prefers the live TinyAgents workspace descriptor at
// execution time when one is present.
Box::new(UpdateMemoryMdTool::new(root_config.workspace_dir.clone())),
// #002: read-only self-diagnosis of the memory pipeline so the agent
// can explain an empty/stalled wiki + the fix.
Box::new(MemoryDoctorTool::new(config.clone())),