mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
TinyAgents migration parity & bug fixes (#4451–#4469) (#4495)
This commit is contained in:
@@ -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':
|
||||
'حاول مرة أخرى؛ وإذا استمر الفشل، شغّل التشخيص من الإعدادات.',
|
||||
|
||||
@@ -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':
|
||||
'আবার চেষ্টা করুন; বারবার ব্যর্থ হলে সেটিংস থেকে ডায়াগনস্টিকস চালান।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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':
|
||||
'दोबारा प्रयास करें; यदि यह बार-बार विफल हो, तो सेटिंग्स से डायग्नोस्टिक्स चलाएँ।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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':
|
||||
'다시 시도하세요. 계속 실패하면 설정에서 진단을 실행하세요.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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':
|
||||
'Повторите попытку; если ошибка повторяется, запустите диагностику в Настройках.',
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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={}",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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`],
|
||||
|
||||
@@ -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: ®ex::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: ®ex::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: ®ex::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: ®ex::Captures| {
|
||||
"sk-*[REDACTED]".to_string()
|
||||
});
|
||||
|
||||
// Space-separated `Bearer <token>`: keep the scheme word, redact the token.
|
||||
BEARER_SPACE_REGEX
|
||||
.replace_all(&stage_openai, |caps: ®ex::Captures| {
|
||||
format!("{} *[REDACTED]", &caps[1])
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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())),
|
||||
|
||||
Reference in New Issue
Block a user