gadget/docs/subagent-context.md
2026-05-17 10:21:41 -04:00

175 lines
7.8 KiB
Markdown

# 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 text
- `toolCall.subagent` — the full `IChatSubagentProcess` object (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:
```typescript
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`:
```typescript
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.subagent` with thinking, toolCalls, stats)
- **Socket emissions**: `agent:complete` event 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.length` for 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`:
```typescript
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 `buildSessionContext` extracts only the response text from `toolCall.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 `subagent` field (pre-fix data)
## Protocol Correctness
The message ordering protocol is intact:
1. `role: "user"` — the user's prompt
2. `role: "assistant"` — the assistant's response, with `toolCalls` array if tools were called
3. `role: "tool"` — tool response, with `toolCallId` matching the call's `id`
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`