mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(conversations): show sub-agent tool input/output in processing drawer (#4007)
This commit is contained in:
@@ -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': 'جارٍ الإلغاء…',
|
||||
|
||||
@@ -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': 'বাতিল করা হচ্ছে…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': 'रद्द किया जा रहा है…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': '취소 중…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': 'Отмена…',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user