Compare commits

..

No commits in common. "29c8c663f17e0300b33fa34835ddac8e434010d4" and "4d8f403d787a9f94b2f7c32796a03a5c66eab6dd" have entirely different histories.

5 changed files with 5 additions and 666 deletions

View File

@ -1,174 +0,0 @@
# 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`

View File

@ -1,162 +0,0 @@
import { useEffect, useRef } from 'react';
interface ExpandedPromptEditorProps {
promptInput: string;
onPromptChange: (value: string) => void;
onSubmit: (e: React.FormEvent) => void;
onClose: () => void;
disabled: boolean;
placeholder: string;
isProcessing: boolean;
isAborting: boolean;
onCancel: () => void;
}
export default function ExpandedPromptEditor({
promptInput,
onPromptChange,
onSubmit,
onClose,
disabled,
placeholder,
isProcessing,
isAborting,
onCancel,
}: ExpandedPromptEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-focus the textarea on mount
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Escape closes the editor
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
// Ctrl+Enter or Cmd+Enter submits the prompt
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (!disabled && promptInput.trim()) {
onSubmit(e);
}
return;
}
// Regular Enter inserts a newline (default textarea behavior)
// No special handling needed — this is the key difference from the simple prompt bar
};
const handleSubmitClick = (e: React.FormEvent) => {
e.preventDefault();
if (!disabled && promptInput.trim()) {
onSubmit(e);
}
};
return (
<div className="flex-1 flex overflow-hidden">
{/* Left Sidebar — Tools */}
<div className="w-48 border-r border-border-subtle bg-bg-secondary flex flex-col shrink-0">
{/* Sidebar Header */}
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary border-b border-border-subtle shrink-0">
<span className="text-xs font-medium text-text-secondary uppercase tracking-wider">Tools</span>
</div>
{/* Sidebar Content — Placeholder for future tools */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div className="text-text-muted text-xs space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
<svg className="w-3.5 h-3.5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
<span className="text-text-secondary font-medium">Snippets</span>
</div>
<p className="pl-5.5 text-text-muted text-[11px] leading-relaxed">
Quick text snippets for common prompt patterns
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<svg className="w-3.5 h-3.5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<span className="text-text-secondary font-medium">Prompt Library</span>
</div>
<p className="pl-5.5 text-text-muted text-[11px] leading-relaxed">
Saved and shared prompt templates
</p>
</div>
</div>
<div className="pt-4 border-t border-border-subtle">
<p className="text-text-muted text-[11px] leading-relaxed">
More tools coming soon...
</p>
</div>
</div>
</div>
{/* Main Editor Area */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header Bar */}
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary border-b border-border-subtle shrink-0">
<span className="text-sm font-medium text-text-secondary">Prompt Editor</span>
<div className="flex items-center gap-2">
{isProcessing ? (
<button
type="button"
onClick={onCancel}
disabled={isAborting}
className="px-4 py-1.5 bg-red-600 text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{isAborting ? 'Aborting...' : 'Cancel'}
</button>
) : (
<button
type="button"
onClick={handleSubmitClick}
disabled={disabled || !promptInput.trim()}
className="px-4 py-1.5 bg-brand text-white rounded hover:bg-brand/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
Submit
</button>
)}
<button
type="button"
onClick={onClose}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-secondary rounded transition-colors"
title="Close expanded editor (Esc)"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Textarea */}
<div className="flex-1 min-h-0 p-4">
<textarea
ref={textareaRef}
value={promptInput}
onChange={(e) => onPromptChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
className="w-full h-full px-4 py-3 bg-bg-primary border border-border-default rounded font-mono text-sm text-text-primary leading-relaxed focus:border-brand focus:outline-none resize-none disabled:opacity-50"
/>
</div>
{/* Footer — Keyboard Shortcuts */}
<div className="px-4 py-2 text-xs text-text-muted border-t border-border-subtle shrink-0 flex items-center justify-between">
<span>Esc to close · Ctrl+Enter to send</span>
<span className="font-mono">{promptInput.length} chars</span>
</div>
</div>
</div>
);
}

View File

@ -10,7 +10,6 @@ import LogPanel from '../components/LogPanel';
import ChatTurnComponent from '../components/ChatTurn';
import EditorPanel from '../components/EditorPanel';
import ErrorBoundary from '../components/ErrorBoundary';
import ExpandedPromptEditor from '../components/ExpandedPromptEditor';
import { AppContext } from '../App';
interface LogEntry {
@ -81,7 +80,6 @@ export default function ChatSessionView() {
const [isOtherTab, setIsOtherTab] = useState(false);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [editorFilePath, setEditorFilePath] = useState<string | undefined>(undefined);
const [isExpandedEditor, setIsExpandedEditor] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@ -1136,24 +1134,8 @@ export default function ChatSessionView() {
<div className="absolute inset-0 bg-bg-primary/80 z-30" />
)}
{/* Expanded Prompt Editor (replaces Chat View when active) */}
{isExpandedEditor ? (
<ExpandedPromptEditor
promptInput={promptInput}
onPromptChange={setPromptInput}
onSubmit={(e) => {
handleSubmitPrompt(e);
setIsExpandedEditor(false);
}}
onClose={() => setIsExpandedEditor(false)}
disabled={promptDisabled}
placeholder={promptPlaceholder}
isProcessing={isProcessing}
isAborting={isAborting}
onCancel={handleCancel}
/>
) : editorFilePath ? (
/* File Editor View (replaces Chat View when file is open) */
{/* File Editor View (replaces Chat View when file is open) */}
{editorFilePath ? (
<ErrorBoundary>
<EditorPanel
workspaceMode={workspaceMode}
@ -1162,7 +1144,7 @@ export default function ChatSessionView() {
/>
</ErrorBoundary>
) : (
/* Chat View */
/* Chat View (75%) */
<div className="flex-1 flex flex-col overflow-hidden">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
@ -1178,17 +1160,6 @@ export default function ChatSessionView() {
{/* Prompt Input */}
<div className="border-t border-border-subtle p-4 bg-bg-secondary shrink-0">
<form onSubmit={handleSubmitPrompt} className="flex gap-2">
<button
type="button"
onClick={() => setIsExpandedEditor(true)}
disabled={promptDisabled || !!editorFilePath}
className="p-2 text-text-muted hover:text-text-primary hover:bg-bg-tertiary rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
title="Open expanded prompt editor"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
</button>
<textarea
ref={inputRef}
value={promptInput}

View File

@ -122,274 +122,6 @@ 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);

View File

@ -303,29 +303,10 @@ 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: toolResponseContent,
content: result,
toolCallId: toolCall.callId,
toolName: toolCall.function.name,
});
@ -434,19 +415,10 @@ 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: toolResponseContent,
content: toolCall.response,
toolCallId: toolCall.callId,
toolName: toolCall.name,
});