diff --git a/.changeset/subagent-tool-call-details.md b/.changeset/subagent-tool-call-details.md new file mode 100644 index 00000000000..8d85a31e708 --- /dev/null +++ b/.changeset/subagent-tool-call-details.md @@ -0,0 +1,23 @@ +--- +'@mastra/core': minor +--- + +Added subagent tool call details to `@mastra/core` harness metadata and live display state so UIs can render repeated subagent tool calls with their nested tool IDs, bounded inputs, and outputs. + +**Before:** + +```typescript +const parsed = parseSubagentMeta(content); + +parsed.toolCalls; +// [{ name: 'read_file', isError: false }] +``` + +**After:** + +```typescript +const parsed = parseSubagentMeta(content); + +parsed.toolCalls; +// [{ toolCallId: 'read-1', name: 'read_file', isError: false, args: { path: '/hello.txt' }, result: '1 | Hello' }] +``` diff --git a/packages/core/src/harness/display-state.test.ts b/packages/core/src/harness/display-state.test.ts index b22ff798089..b21aa645c8d 100644 --- a/packages/core/src/harness/display-state.test.ts +++ b/packages/core/src/harness/display-state.test.ts @@ -586,12 +586,19 @@ describe('subagent lifecycle', () => { type: 'subagent_tool_start', toolCallId: 's1', agentType: 'explore', + subToolCallId: 'call-1', subToolName: 'read_file', - subToolArgs: {}, + subToolArgs: { path: '/hello.txt' }, }); const sub = harness.getDisplayState().activeSubagents.get('s1')!; expect(sub.toolCalls).toHaveLength(1); - expect(sub.toolCalls[0]!.name).toBe('read_file'); + expect(sub.toolCalls[0]).toEqual({ + toolCallId: 'call-1', + name: 'read_file', + isError: false, + args: { path: '/hello.txt' }, + result: null, + }); }); it('marks subagent tool error on subagent_tool_end', () => { @@ -600,6 +607,7 @@ describe('subagent lifecycle', () => { type: 'subagent_tool_start', toolCallId: 's1', agentType: 'explore', + subToolCallId: 'call-1', subToolName: 'read_file', subToolArgs: {}, }); @@ -607,6 +615,7 @@ describe('subagent lifecycle', () => { type: 'subagent_tool_end', toolCallId: 's1', agentType: 'explore', + subToolCallId: 'call-1', subToolName: 'read_file', subToolResult: 'err', isError: true, @@ -615,6 +624,79 @@ describe('subagent lifecycle', () => { expect(sub.toolCalls[0]!.isError).toBe(true); }); + it('matches repeated same-name subagent tool calls by subToolCallId', () => { + emit(harness, { type: 'subagent_start', toolCallId: 's1', agentType: 'explore', task: 't', modelId: 'm' }); + emit(harness, { + type: 'subagent_tool_start', + toolCallId: 's1', + agentType: 'explore', + subToolCallId: 'read-1', + subToolName: 'read_file', + subToolArgs: { path: '/a.txt' }, + }); + emit(harness, { + type: 'subagent_tool_start', + toolCallId: 's1', + agentType: 'explore', + subToolCallId: 'read-2', + subToolName: 'read_file', + subToolArgs: { path: '/b.txt' }, + }); + + emit(harness, { + type: 'subagent_tool_end', + toolCallId: 's1', + agentType: 'explore', + subToolCallId: 'read-1', + subToolName: 'read_file', + subToolResult: 'A', + isError: false, + }); + + const sub = harness.getDisplayState().activeSubagents.get('s1')!; + expect(sub.toolCalls).toEqual([ + { + toolCallId: 'read-1', + name: 'read_file', + isError: false, + args: { path: '/a.txt' }, + result: 'A', + }, + { + toolCallId: 'read-2', + name: 'read_file', + isError: false, + args: { path: '/b.txt' }, + result: null, + }, + ]); + }); + + it('stores a safe fallback when subagent tool results are not JSON-serializable', () => { + emit(harness, { type: 'subagent_start', toolCallId: 's1', agentType: 'explore', task: 't', modelId: 'm' }); + emit(harness, { + type: 'subagent_tool_start', + toolCallId: 's1', + agentType: 'explore', + subToolCallId: 'read-1', + subToolName: 'read_file', + subToolArgs: { path: '/a.txt' }, + }); + + emit(harness, { + type: 'subagent_tool_end', + toolCallId: 's1', + agentType: 'explore', + subToolCallId: 'read-1', + subToolName: 'read_file', + subToolResult: 42n, + isError: false, + }); + + const sub = harness.getDisplayState().activeSubagents.get('s1')!; + expect(sub.toolCalls[0]!.result).toBe('42'); + }); + it('marks subagent as completed on subagent_end', () => { emit(harness, { type: 'subagent_start', toolCallId: 's1', agentType: 'execute', task: 't', modelId: 'm' }); emit(harness, { diff --git a/packages/core/src/harness/harness.ts b/packages/core/src/harness/harness.ts index c56216bfd29..b31bb0e8e54 100644 --- a/packages/core/src/harness/harness.ts +++ b/packages/core/src/harness/harness.ts @@ -2637,7 +2637,13 @@ export class Harness { case 'subagent_tool_start': { const subAgent = ds.activeSubagents.get(event.toolCallId); if (subAgent) { - subAgent.toolCalls.push({ name: event.subToolName, isError: false }); + subAgent.toolCalls.push({ + toolCallId: event.subToolCallId ?? null, + name: event.subToolName, + isError: false, + args: event.subToolArgs ?? null, + result: null, + }); } break; } @@ -2645,9 +2651,12 @@ export class Harness { case 'subagent_tool_end': { const subTool = ds.activeSubagents.get(event.toolCallId); if (subTool) { - const tc = subTool.toolCalls.find(t => t.name === event.subToolName && !t.isError); + const tc = + (event.subToolCallId ? subTool.toolCalls.find(t => t.toolCallId === event.subToolCallId) : undefined) ?? + subTool.toolCalls.findLast(t => t.name === event.subToolName && t.result === null); if (tc) { tc.isError = event.isError; + tc.result = serializeSubagentToolResult(event.subToolResult); } } break; @@ -3118,3 +3127,18 @@ export class Harness { return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; } } + +function serializeSubagentToolResult(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value === 'string') return value; + + try { + return JSON.stringify(value); + } catch { + try { + return String(value); + } catch { + return '[unserializable]'; + } + } +} diff --git a/packages/core/src/harness/subagent-tool.test.ts b/packages/core/src/harness/subagent-tool.test.ts index 1feab2c2c26..70e11968fac 100644 --- a/packages/core/src/harness/subagent-tool.test.ts +++ b/packages/core/src/harness/subagent-tool.test.ts @@ -27,7 +27,7 @@ vi.mock('../workspace/tools/tools', () => ({ createWorkspaceTools: mockCreateWorkspaceTools, })); -import { createSubagentTool } from './tools'; +import { createSubagentTool, parseSubagentMeta } from './tools'; import type { HarnessRequestContext, HarnessSubagent } from './types'; /** @@ -433,3 +433,175 @@ describe('createSubagentTool allowedWorkspaceTools filtering', () => { expect(result.activeTools).toHaveLength(5); }); }); + +describe('createSubagentTool metadata serialization', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('includes sub-tool ids, args, and results in stored metadata', async () => { + mockStream.mockResolvedValue( + createMockStreamResponse('Completed subagent task', [ + { + type: 'tool-call', + payload: { + toolName: 'read_file', + toolCallId: 'read-1', + args: { path: '/a.txt' }, + }, + }, + { + type: 'tool-call', + payload: { + toolName: 'read_file', + toolCallId: 'read-2', + args: { path: '/b.txt' }, + }, + }, + { + type: 'tool-result', + payload: { + toolName: 'read_file', + toolCallId: 'read-1', + result: 'before after', + isError: false, + }, + }, + { + type: 'tool-result', + payload: { + toolName: 'read_file', + toolCallId: 'read-2', + result: 'B', + isError: false, + }, + }, + { + type: 'text-delta', + payload: { + text: 'Completed subagent task', + }, + }, + ]), + ); + + const tool = createSubagentTool({ + subagents, + resolveModel, + fallbackModelId: 'test-model', + }); + + const result = await (tool as any).execute( + { agentType: 'explore', task: 'Read files' }, + { agent: { toolCallId: 'tc-meta-1' } }, + ); + const parsed = parseSubagentMeta(result.content); + + expect(result.isError).toBe(false); + expect(result.content).toContain(''); + expect(parsed.text).toBe('Completed subagent task'); + expect(parsed.toolCalls).toEqual([ + { + toolCallId: 'read-1', + name: 'read_file', + isError: false, + args: { path: '/a.txt' }, + result: 'before after', + }, + { + toolCallId: 'read-2', + name: 'read_file', + isError: false, + args: { path: '/b.txt' }, + result: 'B', + }, + ]); + }); + + it('bounds oversized args in stored metadata', async () => { + const hugeArg = 'x'.repeat(2105); + mockStream.mockResolvedValue( + createMockStreamResponse('Done', [ + { + type: 'tool-call', + payload: { + toolName: 'bash', + toolCallId: 'bash-1', + args: { command: hugeArg }, + }, + }, + { + type: 'tool-result', + payload: { + toolName: 'bash', + toolCallId: 'bash-1', + result: 'ok', + isError: false, + }, + }, + { + type: 'text-delta', + payload: { + text: 'Done', + }, + }, + ]), + ); + + const tool = createSubagentTool({ + subagents, + resolveModel, + fallbackModelId: 'test-model', + }); + + const result = await (tool as any).execute( + { agentType: 'explore', task: 'Run bash' }, + { agent: { toolCallId: 'tc-meta-2' } }, + ); + const parsed = parseSubagentMeta(result.content); + + expect(parsed.toolCalls).toHaveLength(1); + expect(parsed.toolCalls?.[0]).toMatchObject({ + toolCallId: 'bash-1', + name: 'bash', + isError: false, + result: 'ok', + }); + expect(parsed.toolCalls?.[0]?.args).toEqual({ __truncated: expect.any(String) }); + expect((parsed.toolCalls?.[0]?.args as { __truncated: string }).__truncated).toContain('{"command":"'); + expect((parsed.toolCalls?.[0]?.args as { __truncated: string }).__truncated.endsWith('…')).toBe(true); + }); + + it('preserves embedded tool-call text when detailed metadata cannot be decoded', () => { + const parsed = parseSubagentMeta( + 'Done\n%%%not-base64%%%\n', + ); + + expect(parsed).toEqual({ + text: 'Done\n%%%not-base64%%%', + modelId: 'test-model', + durationMs: 42, + toolCalls: [{ toolCallId: null, name: 'read_file', isError: false, args: null, result: null }], + }); + }); + + it('keeps parsing legacy subagent metadata without tool details', () => { + const parsed = parseSubagentMeta( + 'Done\n', + ); + + expect(parsed).toEqual({ + text: 'Done', + modelId: 'test-model', + durationMs: 42, + toolCalls: [ + { toolCallId: null, name: 'read_file', isError: false, args: null, result: null }, + { toolCallId: null, name: 'write_file', isError: true, args: null, result: null }, + ], + }); + }); +}); diff --git a/packages/core/src/harness/tools.ts b/packages/core/src/harness/tools.ts index 7fab42f85d2..b84dd676645 100644 --- a/packages/core/src/harness/tools.ts +++ b/packages/core/src/harness/tools.ts @@ -6,11 +6,19 @@ import type { MastraLanguageModel } from '../llm/model/shared.types'; import { createTool } from '../tools/tool'; import { createWorkspaceTools } from '../workspace/tools/tools'; -import type { HarnessRequestContext, HarnessSubagent } from './types'; +import type { HarnessRequestContext, HarnessSubagent, HarnessSubagentToolCall } from './types'; let questionCounter = 0; let planCounter = 0; +type LoggedSubagentToolCall = { + toolCallId: string; + name: string; + isError?: boolean; + args?: unknown; + result?: unknown; +}; + /** * Built-in harness tool: ask the user a question and wait for their response. * Supports single-select options and free-text input. @@ -433,7 +441,7 @@ Use this tool when: }); let partialText = ''; - const toolCallLog: Array<{ name: string; toolCallId: string; isError?: boolean }> = []; + const toolCallLog: LoggedSubagentToolCall[] = []; try { const response = await subagent.stream(task, { @@ -467,11 +475,16 @@ Use this tool when: break; case 'tool-call': - toolCallLog.push({ name: chunk.payload.toolName, toolCallId: chunk.payload.toolCallId }); + toolCallLog.push({ + name: chunk.payload.toolName, + toolCallId: chunk.payload.toolCallId, + args: chunk.payload.args, + }); emitEvent?.({ type: 'subagent_tool_start', toolCallId, agentType, + subToolCallId: chunk.payload.toolCallId, subToolName: chunk.payload.toolName, subToolArgs: chunk.payload.args, }); @@ -482,6 +495,7 @@ Use this tool when: for (let i = toolCallLog.length - 1; i >= 0; i--) { if (toolCallLog[i]!.toolCallId === chunk.payload.toolCallId && toolCallLog[i]!.isError === undefined) { toolCallLog[i]!.isError = isErr; + toolCallLog[i]!.result = chunk.payload.result; break; } } @@ -489,6 +503,7 @@ Use this tool when: type: 'subagent_tool_end', toolCallId, agentType, + subToolCallId: chunk.payload.toolCallId, subToolName: chunk.payload.toolName, subToolResult: chunk.payload.result, isError: isErr, @@ -544,45 +559,142 @@ Use this tool when: }); } +const MAX_TOOL_DATA_CHARS = 2000; + +function truncateToolData(value: unknown): string | null { + if (value === null || value === undefined) return null; + try { + const str = typeof value === 'string' ? value : JSON.stringify(value); + return str.length > MAX_TOOL_DATA_CHARS ? str.slice(0, MAX_TOOL_DATA_CHARS) + '…' : str; + } catch { + return null; + } +} + +function sanitizeToolArgs(value: unknown): unknown | null { + if (value === null || value === undefined) return null; + if (typeof value !== 'object') { + return truncateToolData(value); + } + + try { + const serialized = JSON.stringify(value); + if (serialized.length <= MAX_TOOL_DATA_CHARS) { + return value; + } + return { __truncated: serialized.slice(0, MAX_TOOL_DATA_CHARS) + '…' }; + } catch { + return null; + } +} + +function encodeBase64(value: string): string { + return Buffer.from(value, 'utf-8').toString('base64'); +} + +function decodeBase64(value: string): string | null { + try { + return Buffer.from(value, 'base64').toString('utf-8'); + } catch { + return null; + } +} + /** - * Build a metadata tag appended to subagent results. - * UIs can parse this to display model ID, duration, and tool calls + * Build a metadata block appended to subagent results. + * UIs can parse this to display model ID, duration, and per-tool args/results * when loading from history (where live events aren't available). + * + * Emits two blocks: + * 1. `` — base64-encoded JSON array with per-tool args and result + * 2. `` — legacy compact summary for backward compatibility */ -function buildSubagentMeta( - modelId: string, - durationMs: number, - toolCalls: Array<{ name: string; isError?: boolean }>, -): string { +function buildSubagentMeta(modelId: string, durationMs: number, toolCalls: LoggedSubagentToolCall[]): string { const tools = toolCalls.map(tc => `${tc.name}:${tc.isError ? 'err' : 'ok'}`).join(','); - return `\n`; + const toolDetails = encodeBase64( + JSON.stringify( + toolCalls.map(tc => ({ + toolCallId: tc.toolCallId, + name: tc.name, + isError: tc.isError ?? false, + args: sanitizeToolArgs(tc.args), + result: truncateToolData(tc.result), + })), + ), + ); + return ( + `\n${toolDetails}` + + `\n` + ); +} + +function parseDetailedToolCalls(content: string): HarnessSubagentToolCall[] | null { + try { + const parsed = JSON.parse(content); + if (!Array.isArray(parsed)) return null; + + return parsed + .filter((item): item is Record => typeof item === 'object' && item !== null) + .map(item => ({ + toolCallId: typeof item['toolCallId'] === 'string' ? item['toolCallId'] : null, + name: typeof item['name'] === 'string' ? item['name'] : 'tool', + isError: item['isError'] === true, + args: 'args' in item ? (item['args'] ?? null) : null, + result: typeof item['result'] === 'string' ? item['result'] : null, + })); + } catch { + return null; + } } /** * Parse subagent metadata from a tool result string. - * Returns the metadata and the cleaned result text (without the tag). + * Returns the metadata and the cleaned result text (without the tags). */ export function parseSubagentMeta(content: string): { text: string; modelId?: string; durationMs?: number; - toolCalls?: Array<{ name: string; isError: boolean }>; + toolCalls?: HarnessSubagentToolCall[]; } { - const match = content.match(/\n$/); - if (!match) return { text: content }; + // Extract detailed tool calls block first (new format) + const toolCallsMatch = content.match( + /\n([\s\S]*?)<\/subagent-tool-calls>/, + ); + let detailedToolCalls: HarnessSubagentToolCall[] | null = null; + let stripped = content; + if (toolCallsMatch) { + const encoding = toolCallsMatch[1]; + const rawPayload = toolCallsMatch[2]!; + const decodedPayload = + encoding === 'base64' ? decodeBase64(rawPayload) : encoding === undefined ? rawPayload : null; + if (decodedPayload !== null) { + detailedToolCalls = parseDetailedToolCalls(decodedPayload); + if (detailedToolCalls) { + stripped = + content.slice(0, toolCallsMatch.index) + content.slice(toolCallsMatch.index! + toolCallsMatch[0].length); + } + } + } - const text = content.slice(0, match.index!); + const match = stripped.match(/\n$/); + if (!match) return { text: stripped }; + + const text = stripped.slice(0, match.index!); const modelId = match[1]; const durationMs = parseInt(match[2]!, 10); - const toolCalls = match[3] - ? match[3] - .split(',') - .filter(Boolean) - .map(entry => { - const [name, status] = entry.split(':'); - return { name: name!, isError: status === 'err' }; - }) - : []; + + const toolCalls = + detailedToolCalls ?? + (match[3] + ? match[3] + .split(',') + .filter(Boolean) + .map(entry => { + const [name, status] = entry.split(':'); + return { toolCallId: null, name: name!, isError: status === 'err', args: null, result: null }; + }) + : []); return { text, modelId, durationMs, toolCalls }; } diff --git a/packages/core/src/harness/types.ts b/packages/core/src/harness/types.ts index 5575caff315..3fa82e95d61 100644 --- a/packages/core/src/harness/types.ts +++ b/packages/core/src/harness/types.ts @@ -458,6 +458,17 @@ export interface ActiveToolState { shellOutput?: string; } +/** + * State of an active subagent execution, tracked by the Harness for UI consumption. + */ +export interface HarnessSubagentToolCall { + toolCallId: string | null; + name: string; + isError: boolean; + args: unknown | null; + result: string | null; +} + /** * State of an active subagent execution, tracked by the Harness for UI consumption. */ @@ -465,7 +476,7 @@ export interface ActiveSubagentState { agentType: string; task: string; modelId?: string; - toolCalls: Array<{ name: string; isError: boolean }>; + toolCalls: HarnessSubagentToolCall[]; textDelta: string; status: 'running' | 'completed' | 'error'; durationMs?: number; @@ -773,6 +784,7 @@ export type HarnessEvent = type: 'subagent_tool_start'; toolCallId: string; agentType: string; + subToolCallId?: string; subToolName: string; subToolArgs: unknown; } @@ -780,6 +792,7 @@ export type HarnessEvent = type: 'subagent_tool_end'; toolCallId: string; agentType: string; + subToolCallId?: string; subToolName: string; subToolResult: unknown; isError: boolean;