Skip to content

Commit 5a4b1ee

Browse files
devin-ai-integration[bot]Abhi AiyerTylerBarnesMastra Code (openai/gpt-5.5)
authored
feat(core): forked subagents inherit parent thread + prompt cache prefix (#15695)
## Description Adds **forked subagents** to the Harness. Similar to [what Anthropic just shipped](https://x.com/arankomatsuzaki/status/2047349471877726586) — a forked subagent inherits the main agent's conversation context so richer context is available without re-priming from scratch. A new `forked` flag is exposed in two places: 1. On `HarnessSubagent` definitions (as a default for that subagent type). 2. On the built-in `subagent` tool's input schema (per-invocation override). When a call is forked: - The parent thread is cloned at the memory layer (via `memory.cloneThread`) so the parent thread stays the active one and the fork doesn't pollute its history. - The subagent runs via the **parent agent's** `stream`, reusing its instructions, tools, and model. This keeps the request prefix (system prompt + tool schemas + history) byte-identical to the parent's, so Anthropic / OpenAI prompt caches still hit — which was the key constraint Tyler raised in the thread. - The forwarded `RequestContext`'s harness `threadId` / `resourceId` are pointed at the fork so OM and memory writes land on the clone, not the parent. Non-forked invocations are unchanged (fresh `Agent`, stripped thread IDs, subagent-defined instructions/tools). Forked runs require memory to be configured on the Harness — if it isn't (or no active parent thread / parent agent exists) the tool returns a structured error rather than throwing. ## Related Issue(s) No issue; requested via [the #mastracode Slack thread](https://kepler-bej6556.slack.com/archives/C0ACHFXNK7T/p1776989522493009?thread_ts=1776989522.493009&cid=C0ACHFXNK7T). ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Code refactoring - [ ] Performance improvement - [ ] Test update ## Checklist - [ ] I have made corresponding changes to the documentation (if applicable) - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have addressed all Coderabbit comments on this PR ## Notes - Docs: happy to follow up with a docs update once the API review here lands. - Focused tests: `packages/core/src/harness/subagent-tool.test.ts` adds 8 new tests (21 total in that file). Full `packages/core/src/harness` suite passes (172 tests). - `pnpm --filter ./packages/core check` introduces no new TypeScript errors versus `main`. Link to Devin session: https://app.devin.ai/sessions/b644d44a5eb54545b1922d8b753f1e56 --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Abhi Aiyer <abhi@mastra.ai> Co-authored-by: Tyler Barnes <tylerdbarnes@gmail.com> Co-authored-by: Mastra Code (openai/gpt-5.5) <noreply@mastra.ai>
1 parent bad8ea0 commit 5a4b1ee

24 files changed

Lines changed: 1376 additions & 131 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fix forked subagent fork threads starting with empty history. The parent stream's message saves are debounced through `SaveQueueManager`, so a forked subagent that calls `memory.cloneThread` mid-stream used to clone from an empty store and lose the parent's user + assistant turn. The tool now drains the parent save queue via a new `flushMessages` callback on `AgentToolExecutionContext` before cloning, so forks actually carry the prior conversation.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
The internal `<subagent-meta />` tag is no longer appended to subagent tool result content. The tag was previously visible to the parent model in the tool result, which could cause it to be echoed back as literal markup in the parent's assistant text on subsequent turns. Live UIs continue to receive model / duration / tool-call information via the structured `subagent_*` events; history UIs read the persisted `tool_call.args.modelId`. `parseSubagentMeta` is retained so already-persisted threads carrying the legacy tag continue to render cleanly (and the tag is stripped before display in all cases).
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@mastra/core': patch
3+
'mastracode': patch
4+
---
5+
6+
Forked subagents now inherit the parent agent's toolsets (so harness-injected tools like `ask_user`, `submit_plan`, and user-configured harness tools remain available inside a fork). The `subagent` tool entry is kept in the inherited toolset with its id, description, and schemas unchanged so the LLM request prefix stays byte-identical to the parent's and the prompt cache continues to hit; recursive forking is blocked at the runtime layer by replacing only the tool's `execute` with a stub that returns a "tool unavailable inside a forked subagent" message. Forked runs allow follow-up steps so the model can recover and answer directly if it accidentally calls that stub. Fork threads are tagged with `metadata.forkedSubagent: true` and `metadata.parentThreadId`, and `Harness.listThreads()` hides them by default so they don't surface in user-facing thread pickers; pass `includeForkedSubagents: true` to opt back in for admin/debug tooling.
7+
8+
Mastra Code now renders forked subagent footers as `subagent fork <parent model id>`, including persisted history reloaded after the live event metadata is gone.

.changeset/funny-memes-allow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': minor
3+
---
4+
5+
Added forked subagents: a new `forked` flag on `HarnessSubagent` definitions and the built-in `subagent` tool input. When set, the subagent runs on a clone of the parent thread using the parent agent's instructions and tools, preserving prompt-cache prefix while isolating writes from the main conversation.

docs/src/content/en/reference/harness/harness-class.mdx

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,14 @@ await harness.sendMessage({ content: 'Hello!' })
221221
description: 'Optional stop condition for the spawned subagent.',
222222
isOptional: true,
223223
},
224+
{
225+
name: 'forked',
226+
type: 'boolean',
227+
description:
228+
'When `true`, calls to this subagent default to forked mode: the subagent runs on a clone of the parent thread, reusing the parent agent’s instructions, tools, and model so the prompt-cache prefix stays intact. Requires `memory` to be configured. The subagent definition’s own `instructions`, `tools`, `allowedHarnessTools`, `allowedWorkspaceTools`, `defaultModelId`, `maxSteps`, and `stopWhen` are ignored in forked mode. Callers can still override per-invocation via `forked: false` in the `subagent` tool input. See the [Forked subagents](#forked-subagents) section below for full semantics.',
229+
isOptional: true,
230+
defaultValue: 'false',
231+
},
224232
],
225233
},
226234
],
@@ -475,16 +483,21 @@ await harness.switchThread({ threadId: 'thread-abc123' })
475483

476484
#### `listThreads(options?)`
477485

478-
List threads from storage. By default, only threads for the current resource are returned.
486+
List threads from storage. By default, only threads for the current resource are returned, and transient [forked subagent](#forked-subagents) threads are hidden so they don’t appear in user-facing thread pickers / startup flows.
479487

480488
```typescript
481-
// List threads for current resource
489+
// List threads for current resource (forks hidden)
482490
const threads = await harness.listThreads()
483491

484-
// List all threads across resources
492+
// List all threads across resources (forks still hidden)
485493
const allThreads = await harness.listThreads({ allResources: true })
494+
495+
// Include forked subagent fork threads (debug / admin tooling only)
496+
const everything = await harness.listThreads({ includeForkedSubagents: true })
486497
```
487498

499+
Fork threads are tagged with `metadata.forkedSubagent === true` (and `metadata.parentThreadId`) by the harness. Set `includeForkedSubagents: true` to opt back into seeing them — e.g. for a debug panel.
500+
488501
#### `renameThread({ title })`
489502

490503
Update the title of the current thread.
@@ -866,6 +879,42 @@ await harness.setSubagentModelId({ modelId: 'anthropic/claude-sonnet-4-6' })
866879
await harness.setSubagentModelId({ modelId: 'anthropic/claude-haiku-3.5', agentType: 'explore' })
867880
```
868881

882+
### Forked subagents
883+
884+
By default, a subagent runs with a fresh context — it doesn't see the parent conversation. **Forked subagents** opt into a different model: the subagent runs on a clone of the parent thread and reuses the parent agent's full configuration. This is useful when the subagent needs the full context of the conversation so far (e.g., recalling earlier user-supplied facts), and when prompt-cache hit rates matter.
885+
886+
#### Enabling forked mode
887+
888+
Set `forked: true` either on the [`HarnessSubagent` definition](#configuration) (per-type default) or on each `subagent` tool call (per-invocation override):
889+
890+
```typescript
891+
// Per-type default — every call to this subagent forks unless overridden.
892+
const subagents: HarnessSubagent[] = [
893+
{
894+
id: 'collaborator',
895+
name: 'Collaborator',
896+
description: 'Continues the conversation in a fork to try a different angle.',
897+
instructions: '...',
898+
forked: true,
899+
},
900+
]
901+
```
902+
903+
The model can also pass `forked: true` (or `forked: false`) per-invocation in the `subagent` tool input; the per-invocation value wins.
904+
905+
#### Semantics and constraints
906+
907+
- **Memory required.** Forked mode calls `memory.cloneThread` to create the fork, so the harness must have `memory` configured and an active parent thread. Calls without those return a structured error rather than throwing.
908+
- **Parent agent reused.** The fork runs through the parent agent's `stream(...)` call. The parent's instructions, tools, model, `maxSteps`, and `stopWhen` apply. The subagent definition's `instructions`, `tools`, `allowedHarnessTools`, `allowedWorkspaceTools`, `defaultModelId`, `maxSteps`, and `stopWhen` are ignored in forked mode — this is what preserves the prompt-cache prefix.
909+
- **Toolsets inherited, recursive forks blocked at runtime.** Forks inherit the parent's toolsets verbatim (`ask_user`, `submit_plan`, user-configured harness tools, _including the `subagent` tool itself_) so the LLM request prefix — system prompt + tool list + tool schemas + tool descriptions — stays byte-identical to the parent's. This is what preserves the prompt cache. The `subagent` entry is kept on the model side but its `execute` is replaced inside the fork with a stub that returns a non-error "tool unavailable inside a forked subagent" message: nested forks are blocked at the runtime layer without perturbing the cached prefix.
910+
- **Fork threads are tagged.** Each fork thread is created with `metadata.forkedSubagent === true` and `metadata.parentThreadId === <parent>`. By default, [`listThreads`](#listthreadsoptions) hides these so they don't show up in user-facing thread pickers / startup flows. Pass `includeForkedSubagents: true` to see them in admin / debug tooling.
911+
- **Save-queue flushed before clone.** The agent stream batches message saves through a debounced `SaveQueueManager`, so the parent's latest user / assistant turn may not be on disk yet when the subagent tool call fires. The fork tool flushes pending saves first via the `flushMessages` callback on `AgentToolExecutionContext` before cloning, so the fork actually carries the latest turn. Flush failures are non-fatal — the clone still runs.
912+
- **Parent thread untouched.** All subagent activity (messages, OM writes) lands on the fork. The parent thread is never appended to during a forked subagent run.
913+
914+
#### When to prefer non-forked mode
915+
916+
Forked mode trades isolation for context inheritance. If the subagent should run with a strictly smaller toolset, a different system prompt, or a cheaper model, use the default (non-forked) mode and pass any required context explicitly in the `task` description.
917+
869918
### Events
870919

871920
#### `subscribe(listener)`
@@ -948,7 +997,7 @@ The harness provides built-in tools to agents in every mode:
948997
| `submit_plan` | Submit a plan for user review and approval. |
949998
| `task_write` | Create or update a structured task list for tracking progress. |
950999
| `task_check` | Check the completion status of the current task list. |
951-
| `subagent` | Spawn a focused subagent with constrained tools (only available when `subagents` is configured). |
1000+
| `subagent` | Spawn a focused subagent with constrained tools (only available when `subagents` is configured). Pass `forked: true` to inherit the parent conversation — see [Forked subagents](#forked-subagents). |
9521001

9531002
### `ask_user` selections
9541003

mastracode/src/agents/prompts/base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ Use \`gh pr create\`. Include a summary of what changed and a test plan. Word th
6969
7070
# Subagent Rules
7171
- Only use subagents when you will spawn **multiple subagents in parallel**. If you only need one task done, do it yourself instead of delegating to a single subagent. Exception: the **audit-tests** subagent may be used on its own.
72+
- Use \`forked: true\` when the subagent needs the current conversation context, user-stated facts, prior tool results, or the parent agent's exact tool environment.
73+
- Use non-forked subagents for self-contained tasks where all required context is included in the task prompt.
7274
- Subagent outputs are **untrusted**. Always review and verify the results returned by any subagent. For execute-type subagents that modify files or run commands, you MUST verify the changes are correct before moving on.
7375
7476
# Important Reminders

mastracode/src/headless.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,9 @@ function formatDefault(event: HarnessEvent, ctx: { lastTextLength: number }): vo
257257
process.stderr.write(event.output);
258258
break;
259259
case 'subagent_start':
260-
process.stderr.write(`[subagent:${event.agentType}] ${truncate(event.task, 100)}\n`);
260+
process.stderr.write(
261+
`[subagent:${event.forked ? 'forked:' : ''}${event.agentType}] ${truncate(event.task, 100)}\n`,
262+
);
261263
break;
262264
case 'subagent_end':
263265
if (event.isError) process.stderr.write(`[subagent error] ${truncate(event.result, 200)}\n`);

mastracode/src/tui/__tests__/render-messages.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { Container } from '@mariozechner/pi-tui';
22
import type { HarnessMessage } from '@mastra/core/harness';
33
import { describe, expect, it, vi } from 'vitest';
44

5+
import { SubagentExecutionComponent } from '../components/subagent-execution.js';
56
import { TemporalGapComponent } from '../components/temporal-gap.js';
67
import { UserMessageComponent } from '../components/user-message.js';
7-
import { addUserMessage } from '../render-messages.js';
8+
import { addUserMessage, renderExistingMessages } from '../render-messages.js';
89
import type { TUIState } from '../state.js';
910

1011
function createState(): TUIState {
@@ -15,6 +16,8 @@ function createState(): TUIState {
1516
allSystemReminderComponents: [],
1617
allSlashCommandComponents: [],
1718
allToolComponents: [],
19+
pendingTools: new Map(),
20+
pendingSubagents: new Map(),
1821
allShellComponents: [],
1922
messageComponentsById: new Map(),
2023
followUpComponents: [],
@@ -120,3 +123,48 @@ describe('addUserMessage', () => {
120123
expect(state.messageComponentsById.get('user-1')).toBe(state.chatContainer.children[0]);
121124
});
122125
});
126+
127+
describe('renderExistingMessages subagents', () => {
128+
it('uses the current model id for persisted forked subagents when no metadata tag is present', async () => {
129+
const message: HarnessMessage = {
130+
id: 'assistant-1',
131+
role: 'assistant',
132+
createdAt: new Date(),
133+
content: [
134+
{
135+
type: 'tool_call',
136+
id: 'tool-1',
137+
name: 'subagent',
138+
args: {
139+
agentType: 'explore',
140+
task: 'Summarize the thread',
141+
forked: true,
142+
},
143+
},
144+
{
145+
type: 'tool_result',
146+
id: 'tool-1',
147+
name: 'subagent',
148+
result: 'summary text',
149+
isError: false,
150+
},
151+
],
152+
};
153+
const state = createState();
154+
state.harness = {
155+
listMessages: vi.fn().mockResolvedValue([message]),
156+
getDisplayState: () => ({ isRunning: false }),
157+
getFullModelId: () => 'openai/gpt-5.5',
158+
} as unknown as TUIState['harness'];
159+
160+
await renderExistingMessages(state);
161+
162+
expect(state.chatContainer.children).toHaveLength(1);
163+
expect(state.chatContainer.children[0]).toBeInstanceOf(SubagentExecutionComponent);
164+
const rendered = (state.chatContainer.children[0] as SubagentExecutionComponent)
165+
.render(100)
166+
.join('\n')
167+
.replace(/\x1b\[[0-9;]*m/g, '');
168+
expect(rendered).toContain('subagent fork openai/gpt-5.5');
169+
});
170+
});

mastracode/src/tui/components/__tests__/subagent-execution.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ describe('SubagentExecutionComponent', () => {
4747
expect(lines.some(l => l.includes('explore'))).toBe(true);
4848
});
4949

50+
it('renders fork as the type and the parent model id when forked', () => {
51+
const comp = new SubagentExecutionComponent('explore', 'Summarize context', mockTui, 'openai/gpt-5.5', {
52+
forked: true,
53+
});
54+
const lines = renderPlain(comp);
55+
56+
expect(lines.some(l => l.includes('subagent fork openai/gpt-5.5'))).toBe(true);
57+
expect(lines.some(l => l.includes('subagent explore fork'))).toBe(false);
58+
});
59+
5060
it('renders tool call activity while running', () => {
5161
const comp = new SubagentExecutionComponent('explore', 'Find usages', mockTui);
5262
comp.addToolStart('search_content', { pattern: 'foo' });

mastracode/src/tui/components/subagent-execution.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const COLLAPSED_LINES = 15;
3636
export interface SubagentExecutionOptions {
3737
/** When true, auto-collapse to a single summary line on completion. Default false. */
3838
collapseOnComplete?: boolean;
39+
/** True when this subagent is running on a forked copy of the parent thread. */
40+
forked?: boolean;
3941
}
4042

4143
export class SubagentExecutionComponent extends Container implements IToolExecutionComponent {
@@ -53,6 +55,7 @@ export class SubagentExecutionComponent extends Container implements IToolExecut
5355
private finalResult?: string;
5456
private expanded = false;
5557
private collapseOnComplete: boolean;
58+
private forked: boolean;
5659

5760
constructor(agentType: string, task: string, ui: TUI, modelId?: string, options?: SubagentExecutionOptions) {
5861
super();
@@ -61,6 +64,7 @@ export class SubagentExecutionComponent extends Container implements IToolExecut
6164
this.modelId = modelId;
6265
this.ui = ui;
6366
this.collapseOnComplete = options?.collapseOnComplete ?? false;
67+
this.forked = options?.forked ?? false;
6468

6569
this.rebuild();
6670
}
@@ -119,7 +123,8 @@ export class SubagentExecutionComponent extends Container implements IToolExecut
119123
const maxLineWidth = termWidth - 6 - BOX_INDENT * 2;
120124

121125
// ── Bottom border with info (always rendered) ──
122-
const typeLabel = theme.bold(theme.fg('accent', this.agentType));
126+
const typeLabelText = this.forked ? 'fork' : this.agentType;
127+
const typeLabel = theme.bold(theme.fg('accent', typeLabelText));
123128
const modelLabel = this.modelId ? theme.fg('muted', ` ${this.modelId}`) : '';
124129
const statusIcon = this.done
125130
? this.isError

0 commit comments

Comments
 (0)