subagent context management
This commit is contained in:
parent
4fa4f29f0b
commit
29c8c663f1
174
docs/subagent-context.md
Normal file
174
docs/subagent-context.md
Normal file
@ -0,0 +1,174 @@
|
||||
# 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`
|
||||
@ -122,6 +122,274 @@ describe("AgentService", () => {
|
||||
expect(messages[2]?.toolCallId).toBe("call-1");
|
||||
});
|
||||
|
||||
it("uses subagent.response instead of full JSON for subagent tool calls in context replay", () => {
|
||||
const user = {
|
||||
_id: "user-1",
|
||||
email: "user@example.com",
|
||||
email_lc: "user@example.com",
|
||||
displayName: "User",
|
||||
flags: {
|
||||
isEmailVerified: true,
|
||||
isAdmin: false,
|
||||
isTest: true,
|
||||
isBanned: false,
|
||||
},
|
||||
};
|
||||
const session = {
|
||||
_id: "session-1",
|
||||
createdAt: new Date(),
|
||||
user,
|
||||
project: "project-1",
|
||||
name: "Test Session",
|
||||
mode: ChatSessionMode.Build,
|
||||
provider: "provider-1",
|
||||
selectedModel: "model",
|
||||
stats: {
|
||||
turnCount: 0,
|
||||
toolCallCount: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
},
|
||||
pins: [],
|
||||
};
|
||||
|
||||
const fullSubagentResult = JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
prompt: "Find the main entry point",
|
||||
thinking: "Let me check package.json first, then look for index files...",
|
||||
response: "The main entry point is src/index.ts as specified in package.json.",
|
||||
toolCalls: [
|
||||
{
|
||||
callId: "sa-call-1",
|
||||
name: "file_read",
|
||||
parameters: '{"path":"package.json"}',
|
||||
response: "PATH: package.json\n---\n{\"main\": \"src/index.ts\"}",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
toolCallCount: 1,
|
||||
inputTokens: 150,
|
||||
thinkingTokenCount: 80,
|
||||
responseTokens: 20,
|
||||
durationMs: 3500,
|
||||
durationLabel: "00:03",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const workOrder: IAgentWorkOrder = {
|
||||
createdAt: new Date(),
|
||||
turn: {
|
||||
_id: "turn-current",
|
||||
createdAt: new Date(),
|
||||
user,
|
||||
project: "project-1",
|
||||
session,
|
||||
provider: "provider-1",
|
||||
llm: "model",
|
||||
mode: ChatSessionMode.Build,
|
||||
status: ChatTurnStatus.Processing,
|
||||
prompts: { user: "continue" },
|
||||
blocks: [],
|
||||
toolCalls: [],
|
||||
subagents: [],
|
||||
stats: {
|
||||
toolCallCount: 0,
|
||||
inputTokens: 0,
|
||||
thinkingTokenCount: 0,
|
||||
responseTokens: 0,
|
||||
durationMs: 0,
|
||||
durationLabel: "pending",
|
||||
},
|
||||
},
|
||||
context: [
|
||||
{
|
||||
_id: "turn-sa-1",
|
||||
createdAt: new Date(),
|
||||
user,
|
||||
project: "project-1",
|
||||
session,
|
||||
provider: "provider-1",
|
||||
llm: "model",
|
||||
mode: ChatSessionMode.Build,
|
||||
status: ChatTurnStatus.Finished,
|
||||
prompts: { user: "Find the main entry point" },
|
||||
blocks: [
|
||||
{
|
||||
mode: "responding",
|
||||
createdAt: new Date(),
|
||||
content: "I'll spawn a subagent to find that.",
|
||||
},
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
callId: "sa-call-master",
|
||||
name: "subagent",
|
||||
parameters: '{"agent_type":"explore","prompt":"Find the main entry point"}',
|
||||
response: fullSubagentResult,
|
||||
subagent: {
|
||||
prompt: "Find the main entry point",
|
||||
thinking: "Let me check package.json first...",
|
||||
response: "The main entry point is src/index.ts as specified in package.json.",
|
||||
toolCalls: [
|
||||
{
|
||||
callId: "sa-call-1",
|
||||
name: "file_read",
|
||||
parameters: '{"path":"package.json"}',
|
||||
response: "PATH: package.json\n---\n{\"main\": \"src/index.ts\"}",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
toolCallCount: 1,
|
||||
inputTokens: 150,
|
||||
thinkingTokenCount: 80,
|
||||
responseTokens: 20,
|
||||
durationMs: 3500,
|
||||
durationLabel: "00:03",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
subagents: [],
|
||||
stats: {
|
||||
toolCallCount: 1,
|
||||
inputTokens: 0,
|
||||
thinkingTokenCount: 0,
|
||||
responseTokens: 0,
|
||||
durationMs: 0,
|
||||
durationLabel: "done",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = service.buildSessionContext(workOrder);
|
||||
|
||||
expect(messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"tool",
|
||||
]);
|
||||
// The tool response should be ONLY the subagent's response text,
|
||||
// not the full JSON blob with thinking, toolCalls, and stats.
|
||||
const toolMessage = messages[2];
|
||||
expect(toolMessage?.role).toBe("tool");
|
||||
expect(toolMessage?.toolCallId).toBe("sa-call-master");
|
||||
expect(toolMessage?.toolName).toBe("subagent");
|
||||
expect(toolMessage?.content).toBe(
|
||||
"The main entry point is src/index.ts as specified in package.json.",
|
||||
);
|
||||
// Explicitly verify the full JSON is NOT in context
|
||||
expect(toolMessage?.content).not.toContain('"success":true');
|
||||
expect(toolMessage?.content).not.toContain('"toolCalls"');
|
||||
expect(toolMessage?.content).not.toContain('"thinking"');
|
||||
});
|
||||
|
||||
it("falls back to toolCall.response for subagent calls without parsed subagent data", () => {
|
||||
const user = {
|
||||
_id: "user-1",
|
||||
email: "user@example.com",
|
||||
email_lc: "user@example.com",
|
||||
displayName: "User",
|
||||
flags: {
|
||||
isEmailVerified: true,
|
||||
isAdmin: false,
|
||||
isTest: true,
|
||||
isBanned: false,
|
||||
},
|
||||
};
|
||||
const session = {
|
||||
_id: "session-1",
|
||||
createdAt: new Date(),
|
||||
user,
|
||||
project: "project-1",
|
||||
name: "Test Session",
|
||||
mode: ChatSessionMode.Build,
|
||||
provider: "provider-1",
|
||||
selectedModel: "model",
|
||||
stats: {
|
||||
turnCount: 0,
|
||||
toolCallCount: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
},
|
||||
pins: [],
|
||||
};
|
||||
const rawResponse = "Subagent completed the task successfully.";
|
||||
|
||||
const workOrder: IAgentWorkOrder = {
|
||||
createdAt: new Date(),
|
||||
turn: {
|
||||
_id: "turn-current",
|
||||
createdAt: new Date(),
|
||||
user,
|
||||
project: "project-1",
|
||||
session,
|
||||
provider: "provider-1",
|
||||
llm: "model",
|
||||
mode: ChatSessionMode.Build,
|
||||
status: ChatTurnStatus.Processing,
|
||||
prompts: { user: "continue" },
|
||||
blocks: [],
|
||||
toolCalls: [],
|
||||
subagents: [],
|
||||
stats: {
|
||||
toolCallCount: 0,
|
||||
inputTokens: 0,
|
||||
thinkingTokenCount: 0,
|
||||
responseTokens: 0,
|
||||
durationMs: 0,
|
||||
durationLabel: "pending",
|
||||
},
|
||||
},
|
||||
context: [
|
||||
{
|
||||
_id: "turn-fallback",
|
||||
createdAt: new Date(),
|
||||
user,
|
||||
project: "project-1",
|
||||
session,
|
||||
provider: "provider-1",
|
||||
llm: "model",
|
||||
mode: ChatSessionMode.Build,
|
||||
status: ChatTurnStatus.Finished,
|
||||
prompts: { user: "Do a task" },
|
||||
blocks: [
|
||||
{
|
||||
mode: "responding",
|
||||
createdAt: new Date(),
|
||||
content: "Spawning a subagent.",
|
||||
},
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
callId: "call-fallback",
|
||||
name: "subagent",
|
||||
parameters: '{"agent_type":"general","prompt":"Do a task"}',
|
||||
response: rawResponse,
|
||||
// subagent field is intentionally omitted — simulates
|
||||
// historical data from before the fix was deployed
|
||||
},
|
||||
],
|
||||
subagents: [],
|
||||
stats: {
|
||||
toolCallCount: 1,
|
||||
inputTokens: 0,
|
||||
thinkingTokenCount: 0,
|
||||
responseTokens: 0,
|
||||
durationMs: 0,
|
||||
durationLabel: "done",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messages = service.buildSessionContext(workOrder);
|
||||
const toolMessage = messages[2];
|
||||
expect(toolMessage?.content).toBe(rawResponse);
|
||||
});
|
||||
|
||||
it("does not expose mutating file tools in plan mode", () => {
|
||||
const toolNames = service.getToolNamesForMode(ChatSessionMode.Plan);
|
||||
|
||||
|
||||
@ -303,10 +303,29 @@ class AgentService extends GadgetService {
|
||||
|
||||
this.currentToolCallId = null;
|
||||
|
||||
// For subagent calls, extract only the response text for the
|
||||
// Master Agent's context. The full JSON (thinking, toolCalls,
|
||||
// stats) is preserved in the DB for auditing and the UI, but it
|
||||
// should never enter the Master Agent's context window.
|
||||
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: result,
|
||||
content: toolResponseContent,
|
||||
toolCallId: toolCall.callId,
|
||||
toolName: toolCall.function.name,
|
||||
});
|
||||
@ -415,10 +434,19 @@ class AgentService extends GadgetService {
|
||||
|
||||
if (turn.toolCalls?.length > 0) {
|
||||
for (const toolCall of turn.toolCalls) {
|
||||
// For subagent calls, use the already-parsed subagent.response
|
||||
// field instead of the full JSON stored in toolCall.response.
|
||||
// This keeps the Master Agent's context window clean — only the
|
||||
// subagent's final answer is relevant to the Master Agent.
|
||||
let toolResponseContent = toolCall.response;
|
||||
if (toolCall.name === "subagent" && toolCall.subagent?.response) {
|
||||
toolResponseContent = toolCall.subagent.response;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
createdAt: turn.createdAt,
|
||||
role: "tool",
|
||||
content: toolCall.response,
|
||||
content: toolResponseContent,
|
||||
toolCallId: toolCall.callId,
|
||||
toolName: toolCall.name,
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user