Skip to content
This repository was archived by the owner on Apr 17, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/subagent-tool-call-details.md
Original file line number Diff line number Diff line change
@@ -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' }]
```
86 changes: 84 additions & 2 deletions packages/core/src/harness/display-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -600,13 +607,15 @@ describe('subagent lifecycle', () => {
type: 'subagent_tool_start',
toolCallId: 's1',
agentType: 'explore',
subToolCallId: 'call-1',
subToolName: 'read_file',
subToolArgs: {},
});
emit(harness, {
type: 'subagent_tool_end',
toolCallId: 's1',
agentType: 'explore',
subToolCallId: 'call-1',
subToolName: 'read_file',
subToolResult: 'err',
isError: true,
Expand All @@ -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, {
Expand Down
28 changes: 26 additions & 2 deletions packages/core/src/harness/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2637,17 +2637,26 @@ export class Harness<TState = {}> {
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;
}

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;
Expand Down Expand Up @@ -3118,3 +3127,18 @@ export class Harness<TState = {}> {
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]';
}
}
}
174 changes: 173 additions & 1 deletion packages/core/src/harness/subagent-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 </subagent-tool-calls> 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('<subagent-tool-calls encoding="base64">');
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 </subagent-tool-calls> 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<subagent-tool-calls encoding="base64">%%%not-base64%%%</subagent-tool-calls>\n<subagent-meta modelId="test-model" durationMs="42" tools="read_file:ok" />',
);

expect(parsed).toEqual({
text: 'Done\n<subagent-tool-calls encoding="base64">%%%not-base64%%%</subagent-tool-calls>',
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<subagent-meta modelId="test-model" durationMs="42" tools="read_file:ok,write_file:err" />',
);

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 },
],
});
});
});
Loading