feat(conversations): show sub-agent tool input/output in processing drawer (#4007)

This commit is contained in:
Cyrus Gray
2026-06-23 15:42:41 -07:00
committed by GitHub
parent a1cf4ad077
commit 8801a929db
29 changed files with 352 additions and 52 deletions
+3
View File
@@ -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': 'جارٍ الإلغاء…',
+3
View File
@@ -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': 'বাতিল করা হচ্ছে…',
+3
View File
@@ -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…',
+3
View File
@@ -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…',
+3
View File
@@ -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…',
+3
View File
@@ -3123,6 +3123,9 @@ const messages: TranslationMap = {
'conversations.subagent.toolCalls': 'Appels doutils',
'conversations.subagent.working': 'En cours…',
'conversations.subagent.noOutputYet': 'Aucun résultat pour linstant',
'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…',
+3
View File
@@ -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': 'रद्द किया जा रहा है…',
+3
View File
@@ -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…',
+3
View File
@@ -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…',
+3
View File
@@ -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': '취소 중…',
+3
View File
@@ -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…',
+3
View File
@@ -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…',
+3
View File
@@ -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': 'Отмена…',
+3
View File
@@ -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': '正在取消…',
@@ -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 (
<ItemWrapper key={`tl-${item.callId}`} divider={turnDivider}>
<div
className="flex items-center gap-2 rounded-md border border-stone-200 bg-stone-50 px-2.5 py-1.5 text-xs dark:border-neutral-800 dark:bg-neutral-800/60"
data-testid="subagent-drawer-tool-call">
<span className={callTone}>🔧</span>
<span className="font-mono text-stone-700 dark:text-neutral-200">
{item.toolName}
</span>
<span className={`ml-auto ${callTone}`}>{statusLabel}</span>
{item.elapsedMs != null && item.status !== 'running' ? (
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{formatElapsed(item.elapsedMs)}
</span>
) : null}
</div>
<ToolCallRow item={item} />
</ItemWrapper>
);
})}
@@ -417,3 +392,115 @@ function ItemWrapper({ divider, children }: { divider: ReactNode; children: Reac
</>
);
}
type SubagentToolItem = Extract<SubagentTranscriptItem, { kind: 'tool' }>;
/**
* 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 (
<div
className="rounded-md border border-stone-200 bg-stone-50 text-xs dark:border-neutral-800 dark:bg-neutral-800/60"
data-testid="subagent-drawer-tool-call">
<button
type="button"
disabled={!expandable}
onClick={() => 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 ? (
<span className="shrink-0 text-[9px] text-stone-400 dark:text-neutral-500">
{expanded ? '▾' : '▸'}
</span>
) : (
<span className="w-[9px] shrink-0" aria-hidden />
)}
<span className={callTone}>🔧</span>
<span className="font-mono text-stone-700 dark:text-neutral-200">{item.toolName}</span>
<span className={`ml-auto ${callTone}`}>{statusLabel}</span>
{item.elapsedMs != null && item.status !== 'running' ? (
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{formatElapsed(item.elapsedMs)}
</span>
) : null}
</button>
{expandable && expanded ? (
<div className="space-y-2 border-t border-stone-200 px-2.5 py-2 dark:border-neutral-800">
{argsText != null ? (
<div data-testid="subagent-tool-call-input">
<div className={detailLabel}>{t('conversations.subagent.input')}</div>
<pre className={detailPre}>{argsText}</pre>
</div>
) : null}
{hasOutput ? (
<div data-testid="subagent-tool-call-output">
<div className={detailLabel}>{t('conversations.subagent.output')}</div>
<pre className={detailPre}>
{item.result && item.result.length > 0
? item.result
: t('conversations.subagent.noOutput')}
</pre>
</div>
) : null}
</div>
) : null}
</div>
);
}
@@ -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(
<SubagentDrawer subagent={activity({ transcript })} status="success" onClose={() => {}} />
);
// 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(
<SubagentDrawer subagent={activity({ transcript })} status="success" onClose={() => {}} />
);
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(
<SubagentDrawer subagent={activity({ transcript })} status="cancelled" onClose={() => {}} />
);
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(
<SubagentDrawer subagent={activity({ transcript })} status="success" onClose={() => {}} />
);
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();
});
});
@@ -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,
})
);
},
+11 -1
View File
@@ -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;
}
+14 -3
View File
@@ -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,
+2
View File
@@ -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,
} => {
@@ -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,
},
+3 -3
View File
@@ -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,
)
@@ -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,
)
@@ -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,
+8
View File
@@ -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,
@@ -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,
},
@@ -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 <N> 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
@@ -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]
+2
View File
@@ -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,
}));