7.8 KiB
Subagent Context Management
Last Updated: 2026-05-17
Status: Fix deployed and tested
The Core Principle
When the Master Agent spawns a subagent, the subagent's internal work (thinking, tool calls, stats) must never enter the Master Agent's context window. Only the subagent's final response text is relevant to the Master Agent. All other data is preserved in the database for UI display and auditing, but excluded from the context sent to the AI model on subsequent API calls.
This is the fundamental design contract of subagents: they absorb noise so the Master Agent doesn't have to.
How It Works
Data Flow
Subagent executes (agent.ts spawnSubagent, line 561)
│
│ Returns: JSON.stringify({ success: true, data: { prompt, thinking, response, toolCalls, stats } })
│
▼
Master Agent receives result (agent.ts process(), line 280)
│
├─► Socket emission to frontend (line 295): agent:complete event
│ • response: extracted response text (clean)
│ • subagent: full data object (for UI/auditing)
│
├─► Database storage (drone-session.ts onToolCall, line 256):
│ • toolCall.response = response text (from agent:complete event)
│ • toolCall.subagent = full data object (from agent:complete event)
│
└─► Master Agent context (agent.ts, line 306-323):
• messages.push({ role: "tool", content: responseText })
Only the subagent's final answer. NOT the full JSON.
Database Storage (Unchanged — Full Data Preserved)
The agent:complete socket event carries both the clean response text and the full subagent data. The DroneSession.onAgentComplete handler in gadget-code/src/lib/drone-session.ts (line 515-533) stores both:
toolCall.response— the subagent's response texttoolCall.subagent— the fullIChatSubagentProcessobject (thinking, toolCalls, stats)
This gives the UI and auditing layer complete visibility into subagent execution while keeping the Master Agent's context clean.
The Two Context Paths
The Master Agent's context is built in two scenarios, and both must apply the subagent filter:
1. Live Context (During a Turn)
File: gadget-drone/src/services/agent.ts, lines 306-323
When the Master Agent's agentic loop processes tool call results, subagent results are parsed and only data.response is added to the messages array:
let toolResponseContent = result;
if (toolCall.function.name === "subagent") {
try {
const parsed = JSON.parse(result);
if (parsed.success && parsed.data?.response) {
toolResponseContent = parsed.data.response;
}
} catch (parseError) {
this.log.warn("failed to parse subagent result for context", {
error: parseError instanceof Error ? parseError.message : String(parseError),
resultLength: result.length,
});
}
}
messages.push({
createdAt: turn.createdAt,
role: "tool",
content: toolResponseContent,
toolCallId: toolCall.callId,
toolName: toolCall.function.name,
});
If JSON parsing fails (which should not happen in normal operation), the system falls back to the raw result and logs a warning. It does not crash.
2. Historical Context (From Previous Turns)
File: gadget-drone/src/services/agent.ts, buildSessionContext(), lines 435-454
When rebuilding context from database records of previous turns, the already-parsed toolCall.subagent.response field is used instead of toolCall.response:
let toolResponseContent = toolCall.response;
if (toolCall.name === "subagent" && toolCall.subagent?.response) {
toolResponseContent = toolCall.subagent.response;
}
messages.push({
createdAt: turn.createdAt,
role: "tool",
content: toolResponseContent,
toolCallId: toolCall.callId,
toolName: toolCall.name,
});
For historical context, no JSON parsing is needed — the data was parsed and stored as a structured IChatSubagentProcess when the agent:complete event was received. The optional chaining toolCall.subagent?.response handles older records where the subagent field may not exist (backward compatibility).
What Is NOT Changed
- Database storage: Still stores full subagent data (
toolCall.subagentwith thinking, toolCalls, stats) - Socket emissions:
agent:completeevent still carries full data to the frontend - Non-subagent tools:
file_read,grep, etc. still have their full responses in context - Token accounting: Still uses
result.lengthfor token estimates (the full result was still generated) - Pruning:
pruneSessionContext()remains a TODO stub (out of scope for this fix)
Types
The type definitions supporting this system live in packages/api/src/interfaces/chat-turn.ts:
interface IChatToolCall {
callId: string;
name: string;
parameters: string;
response: string; // response text (clean for subagents)
subagent?: IChatSubagentProcess; // full data, only for subagent calls
}
interface IChatSubagentProcess {
prompt: string;
thinking?: string;
response: string;
toolCalls: IChatToolCall[];
stats: IChatTurnStats;
}
The subagent field is optional (?) because it only exists on subagent tool call records. Regular tool calls (file_read, grep, etc.) have response but no subagent.
Tests
Tests are in gadget-drone/src/services/agent.test.ts:
- "replays historical tool results as role:tool messages with unaltered content" — verifies non-subagent tool calls still pass through unchanged
- "uses subagent.response instead of full JSON for subagent tool calls in context replay" — verifies
buildSessionContextextracts only the response text fromtoolCall.subagent.response, and explicitly asserts that JSON metadata ("success":true,"toolCalls","thinking") is NOT present - "falls back to toolCall.response for subagent calls without parsed subagent data" — verifies backward compatibility when a subagent tool call record exists in the DB but has no
subagentfield (pre-fix data)
Protocol Correctness
The message ordering protocol is intact:
role: "user"— the user's promptrole: "assistant"— the assistant's response, withtoolCallsarray if tools were calledrole: "tool"— tool response, withtoolCallIdmatching the call'sid
This ordering is maintained in both the live context (agent.ts process()) and historical replay (buildSessionContext). Tool call IDs are preserved correctly.
The fix does not alter message ordering, IDs, or roles. It only changes the content of role: "tool" messages when the tool name is "subagent".
Relationship to HTTP 400 Errors
Before this fix, the full JSON string from subagent results was being placed into role: "tool" message content. This JSON contained nested quotes, brackets, and large payloads that some AI API providers may have rejected or misinterpreted, causing intermittent HTTP 400 errors. By extracting only the response text (a plain string), the tool response content is now clean and consistent with what the API expects.
Future Work
- Pruning:
pruneSessionContext()(agent.ts line 460) is a stub that could be implemented to cull old or redundant tool responses from the context window. This is a separate concern from the subagent context fix. - Context window monitoring: Consider adding debug logging that reports context window size and subagent response size reductions for observability.
References
- Original design document:
docs/subagents.md - Agent implementation:
gadget-drone/src/services/agent.ts - Agent tests:
gadget-drone/src/services/agent.test.ts - Database storage handler:
gadget-code/src/lib/drone-session.ts(onAgentComplete, line 515) - Chat Turn types:
packages/api/src/interfaces/chat-turn.ts - Subagent tool definition:
packages/ai-toolbox/src/chat/subagent.ts