diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 9776fefee..9827f441f 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2993,6 +2993,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': 'استدعاءات الأدوات', 'conversations.subagent.working': 'يعمل…', 'conversations.subagent.noOutputYet': 'لا يوجد ناتج بعد', + 'conversations.subagent.input': 'المدخلات', + 'conversations.subagent.output': 'المخرجات', + 'conversations.subagent.noOutput': 'لم يتم إرجاع أي مخرجات', 'conversations.subagent.close': 'إغلاق', 'conversations.subagent.cancel': 'إلغاء المهمة', 'conversations.subagent.cancelling': 'جارٍ الإلغاء…', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 9794b7c6a..821301f69 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3057,6 +3057,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': 'টুল কল', 'conversations.subagent.working': 'কাজ করছে…', 'conversations.subagent.noOutputYet': 'এখনও কোনো আউটপুট নেই', + 'conversations.subagent.input': 'ইনপুট', + 'conversations.subagent.output': 'আউটপুট', + 'conversations.subagent.noOutput': 'কোনো আউটপুট ফেরত আসেনি', 'conversations.subagent.close': 'বন্ধ করুন', 'conversations.subagent.cancel': 'কাজ বাতিল করুন', 'conversations.subagent.cancelling': 'বাতিল করা হচ্ছে…', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index c24fef911..25fb5ffd8 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3129,6 +3129,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': 'Tool-Aufrufe', 'conversations.subagent.working': 'Arbeitet…', 'conversations.subagent.noOutputYet': 'Noch keine Ausgabe', + 'conversations.subagent.input': 'Eingabe', + 'conversations.subagent.output': 'Ausgabe', + 'conversations.subagent.noOutput': 'Keine Ausgabe zurückgegeben', 'conversations.subagent.close': 'Schließen', 'conversations.subagent.cancel': 'Aufgabe abbrechen', 'conversations.subagent.cancelling': 'Wird abgebrochen…', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 7a1b370a5..607b0ad30 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3587,6 +3587,9 @@ const en: TranslationMap = { 'conversations.subagent.toolCalls': 'Tool calls', 'conversations.subagent.working': 'Working…', 'conversations.subagent.noOutputYet': 'No output yet', + 'conversations.subagent.input': 'Input', + 'conversations.subagent.output': 'Output', + 'conversations.subagent.noOutput': 'No output returned', 'conversations.subagent.close': 'Close', 'conversations.subagent.cancel': 'Cancel task', 'conversations.subagent.cancelling': 'Cancelling…', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 982f40f6f..7fe5a8b09 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3108,6 +3108,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': 'Llamadas a herramientas', 'conversations.subagent.working': 'Trabajando…', 'conversations.subagent.noOutputYet': 'Aún no hay resultados', + 'conversations.subagent.input': 'Entrada', + 'conversations.subagent.output': 'Salida', + 'conversations.subagent.noOutput': 'No se devolvió ninguna salida', 'conversations.subagent.close': 'Cerrar', 'conversations.subagent.cancel': 'Cancelar tarea', 'conversations.subagent.cancelling': 'Cancelando…', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 845fd9035..bcad3d08a 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3123,6 +3123,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': 'Appels d’outils', 'conversations.subagent.working': 'En cours…', 'conversations.subagent.noOutputYet': 'Aucun résultat pour l’instant', + 'conversations.subagent.input': 'Entrée', + 'conversations.subagent.output': 'Sortie', + 'conversations.subagent.noOutput': 'Aucune sortie renvoyée', 'conversations.subagent.close': 'Fermer', 'conversations.subagent.cancel': 'Annuler la tâche', 'conversations.subagent.cancelling': 'Annulation…', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index eb2f76949..714211c4d 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3058,6 +3058,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': 'टूल कॉल', 'conversations.subagent.working': 'काम कर रहा है…', 'conversations.subagent.noOutputYet': 'अभी तक कोई आउटपुट नहीं', + 'conversations.subagent.input': 'इनपुट', + 'conversations.subagent.output': 'आउटपुट', + 'conversations.subagent.noOutput': 'कोई आउटपुट नहीं मिला', 'conversations.subagent.close': 'बंद करें', 'conversations.subagent.cancel': 'कार्य रद्द करें', 'conversations.subagent.cancelling': 'रद्द किया जा रहा है…', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 3ec5b61e0..30a1b9df0 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3061,6 +3061,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': 'Panggilan alat', 'conversations.subagent.working': 'Bekerja…', 'conversations.subagent.noOutputYet': 'Belum ada keluaran', + 'conversations.subagent.input': 'Masukan', + 'conversations.subagent.output': 'Keluaran', + 'conversations.subagent.noOutput': 'Tidak ada keluaran', 'conversations.subagent.close': 'Tutup', 'conversations.subagent.cancel': 'Batalkan tugas', 'conversations.subagent.cancelling': 'Membatalkan…', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index adcfcfcbd..c115ed21c 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3102,6 +3102,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': 'Chiamate agli strumenti', 'conversations.subagent.working': 'In corso…', 'conversations.subagent.noOutputYet': 'Ancora nessun output', + 'conversations.subagent.input': 'Input', + 'conversations.subagent.output': 'Output', + 'conversations.subagent.noOutput': 'Nessun output restituito', 'conversations.subagent.close': 'Chiudi', 'conversations.subagent.cancel': 'Annulla attività', 'conversations.subagent.cancelling': 'Annullamento…', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 10668f88b..bc9652fbb 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3033,6 +3033,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': '도구 호출', 'conversations.subagent.working': '작업 중…', 'conversations.subagent.noOutputYet': '아직 출력이 없습니다', + 'conversations.subagent.input': '입력', + 'conversations.subagent.output': '출력', + 'conversations.subagent.noOutput': '반환된 출력 없음', 'conversations.subagent.close': '닫기', 'conversations.subagent.cancel': '작업 취소', 'conversations.subagent.cancelling': '취소 중…', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 651c3ef2e..3e715e767 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3089,6 +3089,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': 'Wywołania narzędzi', 'conversations.subagent.working': 'Pracuje…', 'conversations.subagent.noOutputYet': 'Brak wyników', + 'conversations.subagent.input': 'Wejście', + 'conversations.subagent.output': 'Wyjście', + 'conversations.subagent.noOutput': 'Brak zwróconych danych wyjściowych', 'conversations.subagent.close': 'Zamknij', 'conversations.subagent.cancel': 'Anuluj zadanie', 'conversations.subagent.cancelling': 'Anulowanie…', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index d224cb948..af3ad1825 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3107,6 +3107,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': 'Chamadas de ferramentas', 'conversations.subagent.working': 'Trabalhando…', 'conversations.subagent.noOutputYet': 'Ainda sem resultado', + 'conversations.subagent.input': 'Entrada', + 'conversations.subagent.output': 'Saída', + 'conversations.subagent.noOutput': 'Nenhuma saída retornada', 'conversations.subagent.close': 'Fechar', 'conversations.subagent.cancel': 'Cancelar tarefa', 'conversations.subagent.cancelling': 'Cancelando…', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 9429307a2..1fd79939a 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3080,6 +3080,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': 'Вызовы инструментов', 'conversations.subagent.working': 'Выполняется…', 'conversations.subagent.noOutputYet': 'Пока нет результата', + 'conversations.subagent.input': 'Ввод', + 'conversations.subagent.output': 'Вывод', + 'conversations.subagent.noOutput': 'Вывод отсутствует', 'conversations.subagent.close': 'Закрыть', 'conversations.subagent.cancel': 'Отменить задачу', 'conversations.subagent.cancelling': 'Отмена…', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 016b51a66..b0560ae66 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2910,6 +2910,9 @@ const messages: TranslationMap = { 'conversations.subagent.toolCalls': '工具调用', 'conversations.subagent.working': '处理中…', 'conversations.subagent.noOutputYet': '暂无输出', + 'conversations.subagent.input': '输入', + 'conversations.subagent.output': '输出', + 'conversations.subagent.noOutput': '无输出返回', 'conversations.subagent.close': '关闭', 'conversations.subagent.cancel': '取消任务', 'conversations.subagent.cancelling': '正在取消…', diff --git a/app/src/pages/conversations/components/SubagentDrawer.tsx b/app/src/pages/conversations/components/SubagentDrawer.tsx index 25c69c17e..bc7615eca 100644 --- a/app/src/pages/conversations/components/SubagentDrawer.tsx +++ b/app/src/pages/conversations/components/SubagentDrawer.tsx @@ -369,34 +369,9 @@ export function SubagentDrawer({ ); } - const callTone = - item.status === 'running' - ? 'text-amber-700 dark:text-amber-300' - : item.status === 'success' - ? 'text-sage-700 dark:text-sage-300' - : 'text-coral-700 dark:text-coral-300'; - const statusLabel = - item.status === 'running' - ? t('conversations.subagent.statusRunning') - : item.status === 'success' - ? t('conversations.subagent.statusCompleted') - : t('conversations.subagent.statusFailed'); return ( - - 🔧 - - {item.toolName} - - {statusLabel} - {item.elapsedMs != null && item.status !== 'running' ? ( - - {formatElapsed(item.elapsedMs)} - - ) : null} - + ); })} @@ -417,3 +392,115 @@ function ItemWrapper({ divider, children }: { divider: ReactNode; children: Reac > ); } + +type SubagentToolItem = Extract; + +/** + * Pretty-print a tool's input arguments for display. Objects/arrays are + * rendered as indented JSON; a string is shown verbatim. Returns `null` when + * there are no arguments to show (e.g. a tool called with no input, or a + * transcript reopened from memory where args weren't persisted). + */ +function formatArgs(args: unknown): string | null { + if (args == null) return null; + if (typeof args === 'string') return args.length > 0 ? args : null; + try { + return JSON.stringify(args, null, 2); + } catch { + return String(args); + } +} + +/** + * One child tool call in the drawer transcript, expandable to reveal exactly + * *what happened*: the input arguments the sub-agent passed and the raw output + * the tool returned. Collapsed by default to keep the transcript scannable; + * the chevron only appears once there's detail to reveal (args present, or the + * call completed with a captured result). Reopened-from-memory transcripts + * carry no args/result, so those rows stay non-expandable. + */ +function ToolCallRow({ item }: { item: SubagentToolItem }) { + const { t } = useT(); + const [expanded, setExpanded] = useState(false); + + const callTone = + item.status === 'running' + ? 'text-amber-700 dark:text-amber-300' + : item.status === 'success' + ? 'text-sage-700 dark:text-sage-300' + : item.status === 'cancelled' + ? 'text-stone-600 dark:text-neutral-300' + : item.status === 'awaiting_user' + ? 'text-amber-700 dark:text-amber-300' + : 'text-coral-700 dark:text-coral-300'; + const statusLabel = + item.status === 'running' + ? t('conversations.subagent.statusRunning') + : item.status === 'success' + ? t('conversations.subagent.statusCompleted') + : item.status === 'cancelled' + ? t('conversations.subagent.statusCancelled') + : item.status === 'awaiting_user' + ? t('conversations.subagent.statusAwaitingUser') + : t('conversations.subagent.statusFailed'); + + const argsText = formatArgs(item.args); + const hasOutput = item.result != null; + const expandable = argsText != null || hasOutput; + + const detailPre = + 'max-h-60 overflow-auto whitespace-pre-wrap break-words rounded bg-white px-2 py-1.5 ' + + 'font-mono text-[11px] leading-relaxed text-stone-600 dark:bg-neutral-900 dark:text-neutral-300'; + const detailLabel = + 'mb-1 text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500'; + + return ( + + setExpanded(v => !v)} + aria-expanded={expandable ? expanded : undefined} + data-testid="subagent-tool-call-toggle" + className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left disabled:cursor-default"> + {expandable ? ( + + {expanded ? '▾' : '▸'} + + ) : ( + + )} + 🔧 + {item.toolName} + {statusLabel} + {item.elapsedMs != null && item.status !== 'running' ? ( + + {formatElapsed(item.elapsedMs)} + + ) : null} + + {expandable && expanded ? ( + + {argsText != null ? ( + + {t('conversations.subagent.input')} + {argsText} + + ) : null} + {hasOutput ? ( + + {t('conversations.subagent.output')} + + {item.result && item.result.length > 0 + ? item.result + : t('conversations.subagent.noOutput')} + + + ) : null} + + ) : null} + + ); +} diff --git a/app/src/pages/conversations/components/__tests__/SubagentDrawer.test.tsx b/app/src/pages/conversations/components/__tests__/SubagentDrawer.test.tsx index d74fb7324..c5ecdc8f0 100644 --- a/app/src/pages/conversations/components/__tests__/SubagentDrawer.test.tsx +++ b/app/src/pages/conversations/components/__tests__/SubagentDrawer.test.tsx @@ -242,4 +242,75 @@ describe('SubagentDrawer', () => { // Case-robust: the label may be rendered as "Cancelled" or "cancelled". expect(screen.getByTestId('subagent-drawer').textContent?.toLowerCase()).toContain('cancelled'); }); + + it('expands a tool call to reveal its input args and output', async () => { + const transcript: SubagentTranscriptItem[] = [ + { + kind: 'tool', + iteration: 1, + callId: 'c1', + toolName: 'web_search', + status: 'success', + elapsedMs: 1200, + args: { query: 'Q3 revenue drivers' }, + result: 'Found 3 results about revenue.', + }, + ]; + render( + {}} /> + ); + + // Collapsed by default — neither input nor output is rendered yet. + expect(screen.queryByTestId('subagent-tool-call-input')).toBeNull(); + expect(screen.queryByTestId('subagent-tool-call-output')).toBeNull(); + + await userEvent.click(screen.getByTestId('subagent-tool-call-toggle')); + + expect(screen.getByTestId('subagent-tool-call-input').textContent).toContain( + 'Q3 revenue drivers' + ); + expect(screen.getByTestId('subagent-tool-call-output').textContent).toContain( + 'Found 3 results about revenue.' + ); + }); + + it('shows the no-output placeholder when the tool returned an empty result', async () => { + const transcript: SubagentTranscriptItem[] = [ + { kind: 'tool', iteration: 1, callId: 'c1', toolName: 'noop', status: 'success', result: '' }, + ]; + render( + {}} /> + ); + await userEvent.click(screen.getByTestId('subagent-tool-call-toggle')); + expect(screen.getByTestId('subagent-tool-call-output').textContent?.toLowerCase()).toContain( + 'no output' + ); + }); + + it('renders cancelled/awaiting_user tool-call statuses with their own label (not "failed")', () => { + const transcript: SubagentTranscriptItem[] = [ + { kind: 'tool', iteration: 1, callId: 'c1', toolName: 'web_search', status: 'cancelled' }, + { kind: 'tool', iteration: 1, callId: 'c2', toolName: 'composio', status: 'awaiting_user' }, + ]; + render( + {}} /> + ); + const rows = screen.getAllByTestId('subagent-drawer-tool-call'); + expect(rows[0].textContent?.toLowerCase()).toContain('cancelled'); + expect(rows[0].textContent?.toLowerCase()).not.toContain('failed'); + expect(rows[1].textContent?.toLowerCase()).toContain('awaiting'); + }); + + it('does not offer expansion for a tool call with no captured args or result', () => { + const transcript: SubagentTranscriptItem[] = [ + { kind: 'tool', iteration: 1, callId: 'c1', toolName: 'web_search', status: 'success' }, + ]; + render( + {}} /> + ); + const toggle = screen.getByTestId('subagent-tool-call-toggle') as HTMLButtonElement; + expect(toggle.disabled).toBe(true); + expect(screen.queryByTestId('subagent-tool-call-input')).toBeNull(); + expect(screen.queryByTestId('subagent-tool-call-output')).toBeNull(); + }); }); diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index 9e0911bc1..33ec6c6ca 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -641,6 +641,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { toolName: event.tool_name, status: 'running', iteration: event.subagent?.child_iteration, + args: event.args, }, ], }, @@ -655,6 +656,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { callId: event.tool_call_id, toolName: event.tool_name, iteration: event.subagent?.child_iteration, + args: event.args, }) ); }, @@ -676,6 +678,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { status: event.success ? 'success' : 'error', elapsedMs: event.subagent?.elapsed_ms ?? updatedCalls[callIdx].elapsedMs, outputChars: event.subagent?.output_chars ?? updatedCalls[callIdx].outputChars, + result: event.output ?? updatedCalls[callIdx].result, }; const next = [...existing]; next[idx] = { ...entry, subagent: { ...entry.subagent, toolCalls: updatedCalls } }; @@ -688,6 +691,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { success: event.success, elapsedMs: event.subagent?.elapsed_ms, outputChars: event.subagent?.output_chars, + result: event.output, }) ); }, diff --git a/app/src/services/chatService.ts b/app/src/services/chatService.ts index 8b067d67f..c15bcea21 100644 --- a/app/src/services/chatService.ts +++ b/app/src/services/chatService.ts @@ -301,6 +301,12 @@ export interface ChatSubagentToolCallEvent { skill_id: string; /** Provider-assigned tool call id. */ tool_call_id: string; + /** + * Full arguments the sub-agent invoked the tool with, so the processing + * drawer can show *what exactly* the child did. Absent for tools called + * with no/`null` arguments. + */ + args?: unknown; subagent?: SubagentProgressDetail; } @@ -313,7 +319,11 @@ export interface ChatSubagentToolResultEvent { skill_id: string; tool_call_id: string; success: boolean; - /** Stringified JSON `{ output_chars, elapsed_ms }` matching `tool_result`. */ + /** + * The child tool's actual output text, so the drawer can show what came + * back. Size/timing still arrive via `subagent.output_chars` / + * `subagent.elapsed_ms`. + */ output?: string; subagent?: SubagentProgressDetail; } diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 609e50053..a9a300cae 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -133,6 +133,10 @@ export type SubagentTranscriptItem = status: ToolTimelineEntryStatus; elapsedMs?: number; outputChars?: number; + /** Arguments the child invoked the tool with (set on start). */ + args?: unknown; + /** The tool's actual output text (set on completion). */ + result?: string; }; /** One child tool call performed by a running sub-agent. */ @@ -148,6 +152,10 @@ export interface SubagentToolCallEntry { elapsedMs?: number; /** Character length of the tool result (set on completion). */ outputChars?: number; + /** Arguments the child invoked the tool with (set on start). */ + args?: unknown; + /** The tool's actual output text (set on completion). */ + result?: string; } export interface ToolTimelineEntry { @@ -630,14 +638,15 @@ const chatRuntimeSlice = createSlice({ callId: string; toolName: string; iteration?: number; + args?: unknown; }> ) => { - const { threadId, rowId, callId, toolName, iteration } = action.payload; + const { threadId, rowId, callId, toolName, iteration, args } = action.payload; const entry = state.toolTimelineByThread[threadId]?.find(e => e.id === rowId); if (!entry?.subagent) return; const transcript = (entry.subagent.transcript ??= []); if (transcript.some(i => i.kind === 'tool' && i.callId === callId)) return; - transcript.push({ kind: 'tool', iteration, callId, toolName, status: 'running' }); + transcript.push({ kind: 'tool', iteration, callId, toolName, status: 'running', args }); }, /** * Flip a transcript `tool` item to its terminal status when the child @@ -653,15 +662,17 @@ const chatRuntimeSlice = createSlice({ success: boolean; elapsedMs?: number; outputChars?: number; + result?: string; }> ) => { - const { threadId, rowId, callId, success, elapsedMs, outputChars } = action.payload; + const { threadId, rowId, callId, success, elapsedMs, outputChars, result } = action.payload; const entry = state.toolTimelineByThread[threadId]?.find(e => e.id === rowId); const item = entry?.subagent?.transcript?.find(i => i.kind === 'tool' && i.callId === callId); if (!item || item.kind !== 'tool') return; item.status = success ? 'success' : 'error'; if (elapsedMs != null) item.elapsedMs = elapsedMs; if (outputChars != null) item.outputChars = outputChars; + if (result != null) item.result = result; }, setTaskBoardForThread: ( state, diff --git a/src/bin/harness_subagent_audit.rs b/src/bin/harness_subagent_audit.rs index fa0940766..aa114437b 100644 --- a/src/bin/harness_subagent_audit.rs +++ b/src/bin/harness_subagent_audit.rs @@ -555,6 +555,7 @@ async fn drain_progress( task_id, call_id, tool_name, + arguments: _, iteration, } => { eprintln!( @@ -581,6 +582,7 @@ async fn drain_progress( tool_name, success, output_chars, + output: _, elapsed_ms, iteration, } => { diff --git a/src/openhuman/agent/harness/engine/progress.rs b/src/openhuman/agent/harness/engine/progress.rs index 605ef5264..aa0a67326 100644 --- a/src/openhuman/agent/harness/engine/progress.rs +++ b/src/openhuman/agent/harness/engine/progress.rs @@ -40,7 +40,7 @@ pub(crate) trait ProgressReporter: Send + Sync { _call_id: &str, _tool_name: &str, _success: bool, - _output_chars: usize, + _output: &str, _elapsed_ms: u64, _iteration: u32, ) { @@ -158,7 +158,7 @@ impl ProgressReporter for TurnProgress { call_id: &str, tool_name: &str, success: bool, - output_chars: usize, + output: &str, elapsed_ms: u64, iteration: u32, ) { @@ -169,7 +169,7 @@ impl ProgressReporter for TurnProgress { call_id: call_id.to_string(), tool_name: tool_name.to_string(), success, - output_chars, + output_chars: output.chars().count(), elapsed_ms, iteration, }, @@ -217,7 +217,7 @@ impl ProgressReporter for SubagentProgress { &self, call_id: &str, tool_name: &str, - _arguments: &serde_json::Value, + arguments: &serde_json::Value, iteration: u32, ) { if let Some(ref sink) = self.sink { @@ -228,6 +228,7 @@ impl ProgressReporter for SubagentProgress { task_id: self.task_id.clone(), call_id: call_id.to_string(), tool_name: tool_name.to_string(), + arguments: arguments.clone(), iteration, }, ); @@ -239,7 +240,7 @@ impl ProgressReporter for SubagentProgress { call_id: &str, tool_name: &str, success: bool, - output_chars: usize, + output: &str, elapsed_ms: u64, iteration: u32, ) { @@ -252,7 +253,8 @@ impl ProgressReporter for SubagentProgress { call_id: call_id.to_string(), tool_name: tool_name.to_string(), success, - output_chars, + output_chars: output.chars().count(), + output: output.to_string(), elapsed_ms, iteration, }, diff --git a/src/openhuman/agent/harness/engine/tools.rs b/src/openhuman/agent/harness/engine/tools.rs index 9ed91893f..9e1a0ca3b 100644 --- a/src/openhuman/agent/harness/engine/tools.rs +++ b/src/openhuman/agent/harness/engine/tools.rs @@ -71,14 +71,14 @@ pub(crate) async fn run_one_tool( // (denied / CliRpcOnly / unknown) so the client row flips to `error` // instead of staying running. let emit_failed_completion = |message: &str| { - let output_chars = message.chars().count(); + let message = message.to_string(); async move { progress .tool_completed( progress_call_id, &call.name, false, - output_chars, + &message, 0, iteration_u32, ) @@ -361,7 +361,7 @@ pub(crate) async fn run_one_tool( progress_call_id, &call.name, success, - result_text.chars().count(), + &result_text, elapsed_ms, iteration_u32, ) diff --git a/src/openhuman/agent/harness/session/agent_tool_exec.rs b/src/openhuman/agent/harness/session/agent_tool_exec.rs index 18480a8b9..b8f7bc8d7 100644 --- a/src/openhuman/agent/harness/session/agent_tool_exec.rs +++ b/src/openhuman/agent/harness/session/agent_tool_exec.rs @@ -279,7 +279,7 @@ pub(super) async fn run_agent_tool_call( &call_id, &call.name, success, - result.chars().count(), + &result, elapsed_ms, (iteration + 1) as u32, ) diff --git a/src/openhuman/agent/harness/subagent_runner/ops/tool_source.rs b/src/openhuman/agent/harness/subagent_runner/ops/tool_source.rs index 09a5d7565..97c265e0f 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/tool_source.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/tool_source.rs @@ -86,14 +86,7 @@ impl super::super::super::engine::ToolSource for SubagentToolSource<'_> { available.join(", ") ); progress - .tool_completed( - progress_call_id, - &call.name, - false, - text.chars().count(), - 0, - iteration_u32, - ) + .tool_completed(progress_call_id, &call.name, false, &text, 0, iteration_u32) .await; return super::super::super::engine::ToolRunResult { text, diff --git a/src/openhuman/agent/progress.rs b/src/openhuman/agent/progress.rs index 754d7077e..1e1aee668 100644 --- a/src/openhuman/agent/progress.rs +++ b/src/openhuman/agent/progress.rs @@ -149,6 +149,10 @@ pub enum AgentProgress { task_id: String, call_id: String, tool_name: String, + /// Full arguments the child invoked the tool with, so the parent + /// thread's UI can show *what exactly* the sub-agent did (not just + /// the tool name). Mirrors the top-level `ToolCallStarted.arguments`. + arguments: serde_json::Value, /// 1-based child iteration index this call belongs to. iteration: u32, }, @@ -161,6 +165,10 @@ pub enum AgentProgress { tool_name: String, success: bool, output_chars: usize, + /// Full text the tool returned, so the UI can show the sub-agent's + /// actual result/output. `output_chars` is kept as a cheap size hint + /// for consumers that only want the length. + output: String, elapsed_ms: u64, /// 1-based child iteration index. iteration: u32, diff --git a/src/openhuman/agent/progress_tracing/tests.rs b/src/openhuman/agent/progress_tracing/tests.rs index a860b4139..8d9a2b599 100644 --- a/src/openhuman/agent/progress_tracing/tests.rs +++ b/src/openhuman/agent/progress_tracing/tests.rs @@ -265,6 +265,7 @@ fn subagent_lifecycle_nests_under_the_turn() { task_id: "task-1".to_string(), call_id: "sc-1".to_string(), tool_name: "read_file".to_string(), + arguments: serde_json::Value::Null, iteration: 1, }, 30, @@ -277,6 +278,7 @@ fn subagent_lifecycle_nests_under_the_turn() { tool_name: "read_file".to_string(), success: true, output_chars: 99, + output: "file contents".to_string(), elapsed_ms: 40, iteration: 1, }, diff --git a/src/openhuman/channels/providers/web/progress_bridge.rs b/src/openhuman/channels/providers/web/progress_bridge.rs index 19de45351..4228bcb9b 100644 --- a/src/openhuman/channels/providers/web/progress_bridge.rs +++ b/src/openhuman/channels/providers/web/progress_bridge.rs @@ -15,6 +15,40 @@ fn unix_epoch_ms() -> u64 { .unwrap_or(0) } +/// Upper bound on the sub-agent tool output forwarded to the drawer over +/// Socket.IO. The `SubagentToolCallCompleted` event carries the *pre-handoff* +/// tool result (the result-handoff path that stashes large toolkit payloads +/// behind a short placeholder runs later, in `SubagentToolSource`), so a raw +/// multi-MB integration result would otherwise ship in full to the socket / +/// Redux / DOM. Cap it here on a UTF-8 boundary with a truncation marker so the +/// drawer payload stays bounded while still showing what the tool returned. +const MAX_WIRE_SUBAGENT_OUTPUT: usize = 256 * 1024; + +/// Bytes reserved within the cap for the truncation marker so the *final* +/// payload (content + marker) never exceeds [`MAX_WIRE_SUBAGENT_OUTPUT`]. +/// Generous upper bound for `…[truncated bytes of tool output]` at any +/// plausible `N` (the "…" is 3 UTF-8 bytes). +const TRUNCATION_MARKER_BUDGET: usize = 80; + +/// Truncate `output` so the returned string stays within +/// [`MAX_WIRE_SUBAGENT_OUTPUT`] bytes, slicing on a char boundary and +/// appending a marker (which is itself counted against the cap) when content +/// was dropped. Returns the input unchanged when it's already within the cap. +fn cap_wire_output(output: String) -> String { + if output.len() <= MAX_WIRE_SUBAGENT_OUTPUT { + return output; + } + let mut end = MAX_WIRE_SUBAGENT_OUTPUT.saturating_sub(TRUNCATION_MARKER_BUDGET); + while end > 0 && !output.is_char_boundary(end) { + end -= 1; + } + let omitted = output.len() - end; + format!( + "{}\n…[truncated {omitted} bytes of tool output]", + &output[..end] + ) +} + pub(super) fn ledger_upsert_agent_run( config: &crate::openhuman::config::Config, upsert: crate::openhuman::session_db::run_ledger::AgentRunUpsert, @@ -689,6 +723,7 @@ pub(crate) fn spawn_progress_bridge( task_id, call_id, tool_name, + arguments, iteration, } => { let count = child_tool_counts.entry(task_id.clone()).or_insert(0); @@ -721,6 +756,14 @@ pub(crate) fn spawn_progress_bridge( request_id: request_id.clone(), tool_name: Some(tool_name), skill_id: Some(task_id.clone()), + // The child's tool arguments, so the UI can show what + // the sub-agent actually did (issue: subagent drawer + // detail). Skipped from the wire when `null`. + args: if arguments.is_null() { + None + } else { + Some(arguments) + }, round: Some(round), tool_call_id: Some(call_id), subagent: Some(SubagentProgressDetail { @@ -739,6 +782,7 @@ pub(crate) fn spawn_progress_bridge( tool_name, success, output_chars, + output, elapsed_ms, iteration, } => { @@ -768,10 +812,11 @@ pub(crate) fn spawn_progress_bridge( success: Some(success), round: Some(round), tool_call_id: Some(call_id), - output: Some( - json!({"output_chars": output_chars, "elapsed_ms": elapsed_ms}) - .to_string(), - ), + // The child's actual tool output, so the drawer can show + // *what came back* (not just a char count). Capped to a + // bounded size for the wire (#4007); `output_chars` + + // `elapsed_ms` still ride along in `subagent` below. + output: Some(cap_wire_output(output)), subagent: Some(SubagentProgressDetail { child_iteration: Some(iteration), agent_id: Some(agent_id), @@ -1027,6 +1072,26 @@ pub(crate) fn spawn_progress_bridge( mod tests { use super::*; + #[test] + fn cap_wire_output_passes_through_small_payloads() { + let s = "small tool result".to_string(); + assert_eq!(cap_wire_output(s.clone()), s); + } + + #[test] + fn cap_wire_output_truncates_large_payloads_on_char_boundary() { + // A multibyte payload past the cap: result stays valid UTF-8, is shorter + // than the input, and carries the truncation marker. + let big = "é".repeat(MAX_WIRE_SUBAGENT_OUTPUT); // 2 bytes each → well over cap + let capped = cap_wire_output(big.clone()); + assert!(capped.len() < big.len()); + assert!(capped.contains("[truncated")); + // Truncation landed on a char boundary (no replacement char / panic). + assert!(capped.starts_with('é')); + // The final payload (content + marker) must honor the wire cap. + assert!(capped.len() <= MAX_WIRE_SUBAGENT_OUTPUT); + } + #[test] fn worktree_detail_collapses_empty_changed_files_to_none() { // Non-isolated / clean worker: empty list → `None` so the renderer diff --git a/src/openhuman/threads/turn_state/mirror_tests.rs b/src/openhuman/threads/turn_state/mirror_tests.rs index b02b32404..9aa9b7843 100644 --- a/src/openhuman/threads/turn_state/mirror_tests.rs +++ b/src/openhuman/threads/turn_state/mirror_tests.rs @@ -238,6 +238,7 @@ fn subagent_lifecycle_records_and_clears_active() { task_id: "sub-1".into(), call_id: "ctc-1".into(), tool_name: "search".into(), + arguments: serde_json::Value::Null, iteration: 1, }); let activity = m.snapshot().tool_timeline[0] diff --git a/tests/memory_threads_raw_coverage_e2e.rs b/tests/memory_threads_raw_coverage_e2e.rs index 9a51d8fe0..394a92138 100644 --- a/tests/memory_threads_raw_coverage_e2e.rs +++ b/tests/memory_threads_raw_coverage_e2e.rs @@ -3179,6 +3179,7 @@ fn turn_state_mirror_persists_progress_edges_from_public_events() { task_id: "task-1".into(), call_id: "child-call".into(), tool_name: "memory.read".into(), + arguments: serde_json::Value::Null, iteration: 1, })); assert!(!mirror.observe(&AgentProgress::SubagentToolCallCompleted { @@ -3188,6 +3189,7 @@ fn turn_state_mirror_persists_progress_edges_from_public_events() { tool_name: "memory.read".into(), success: true, output_chars: 44, + output: "child tool output".into(), elapsed_ms: 22, iteration: 1, }));
{argsText}
+ {item.result && item.result.length > 0 + ? item.result + : t('conversations.subagent.noOutput')} +