Part 12: Sub-agents
Sub-agents solve context pollution. Sometimes the model needs to do noisy work — search a repo, read ten files, compare implementations — just to answer one question. Without sub-agents, all that intermediate work stays in the parent conversation forever. Sub-agents push it into an isolated child run that returns only a summary.
What you are building
Starting from Part 11, you add:
src/sub-agent.ts— isolated child agent execution- Updated
src/agentic-loop.ts— imports and registers the sub-agent tool, routessub_agenttool calls to the child runner
Step 1: Create src/sub-agent.ts
Create the file:
touch src/sub-agent.tsThe sub-agent.ts file will look very similar to the existing agentic-loop.ts file. That is because it has it's own loop, with a few variations. For instance, the sub-agent.ts must run autonomously and be a background agent, while the main agentic-loop.ts interacts with the user.The sub-agent gets its own message history, system prompt, and tool access.
This is the analogy: With subagents, the parent delegates a research task to a specialist. That specialist might read 20 files, run grep, compare implementations — messy exploratory work. All those intermediate steps stay in the specialist's notebook. When they're done, they hand the parent a single summary. The parent never sees the messy drafts, only the clean result.
// src/sub-agent.ts
/**
* Sub-agents — Spawn isolated child agent sessions.
*
* Sub-agents prevent context pollution by running tasks in a separate
* message history. The parent agent delegates a task, the sub-agent
* executes it with its own tool calls, and returns a summary.
*
* This is exposed as a `sub_agent` tool that the main agent can call.
*/
import type OpenAI from 'openai';
import crypto from 'node:crypto';
import { handleToolCall, getAllTools } from './tools/index.js';
import { generateSystemPrompt } from './system-prompt.js';
import { logger } from './utils/logger.js';
import { clearTodos } from './tools/todo.js';
import { calculateCost, type ModelPricing, type UsageInfo } from './utils/cost-tracker.js';
export const subAgentTool = {
type: 'function' as const,
function: {
name: 'sub_agent',
description:
'Spawn an isolated sub-agent to handle a task without polluting the main conversation context. ' +
'Use this for independent subtasks like exploring a codebase, researching a question, or making changes to a separate area. ' +
'The sub-agent has access to the same tools but runs in its own conversation.',
parameters: {
type: 'object',
properties: {
task: {
type: 'string',
description: 'A detailed description of the task for the sub-agent to complete.',
},
max_iterations: {
type: 'number',
description: 'Maximum tool-call iterations for the sub-agent. Defaults to 500.',
},
},
required: ['task'],
},
},
};
export type SubAgentProgressHandler = (event: { tool: string; status: 'running' | 'done' | 'error'; iteration: number; args?: Record<string, unknown> }) => void;
/** Sub-agent usage stats (uses main UsageInfo type for consistency). */
export type SubAgentUsage = UsageInfo;
export interface SubAgentResult {
response: string;
usage: SubAgentUsage;
}
/**
* Run a sub-agent with its own isolated conversation.
* Returns the sub-agent's final text response.
*/
export async function runSubAgent(
client: OpenAI,
model: string,
task: string,
maxIterations = 500,
requestDefaults: Record<string, unknown> = {},
onProgress?: SubAgentProgressHandler,
abortSignal?: AbortSignal,
pricing?: ModelPricing,
): Promise<SubAgentResult> {
const op = logger.startOperation('sub-agent');
const subAgentSessionId = `sub-agent-${crypto.randomUUID()}`;
const systemPrompt = await generateSystemPrompt();
const subSystemPrompt = `${systemPrompt}
## Sub-Agent Mode
You are running as a sub-agent. You were given a specific task by the parent agent.
Complete the task thoroughly and return a clear, concise summary of what you did and found.
Do NOT ask the user questions — work autonomously with the tools available.`;
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: 'system', content: subSystemPrompt },
{ role: 'user', content: task },
];
// Track cumulative usage across all API calls in the sub-agent
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalCost = 0;
try {
for (let i = 0; i < maxIterations; i++) {
// Check abort at the top of each iteration
if (abortSignal?.aborted) {
return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
}
let assistantMessage: any;
let hasToolCalls = false;
try {
const stream = await client.chat.completions.create({
...requestDefaults,
model,
messages,
tools: getAllTools(),
tool_choice: 'auto',
stream: true,
stream_options: { include_usage: true },
}, { signal: abortSignal });
// Accumulate the streamed response
assistantMessage = {
role: 'assistant',
content: '',
tool_calls: [],
};
let streamedContent = '';
hasToolCalls = false;
let actualUsage: OpenAI.CompletionUsage | undefined;
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (chunk.usage) {
actualUsage = chunk.usage;
}
// Stream text content
if (delta?.content) {
streamedContent += delta.content;
assistantMessage.content = streamedContent;
}
// Accumulate tool calls across stream chunks
if (delta?.tool_calls) {
hasToolCalls = true;
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (!assistantMessage.tool_calls[idx]) {
assistantMessage.tool_calls[idx] = {
id: '',
type: 'function',
function: { name: '', arguments: '' },
};
}
if (tc.id) assistantMessage.tool_calls[idx].id = tc.id;
if (tc.function?.name) {
assistantMessage.tool_calls[idx].function.name += tc.function.name;
}
if (tc.function?.arguments) {
assistantMessage.tool_calls[idx].function.arguments += tc.function.arguments;
}
}
}
}
// Accumulate usage for this iteration
const iterationInputTokens = actualUsage?.prompt_tokens || 0;
const iterationOutputTokens = actualUsage?.completion_tokens || 0;
const iterationCachedTokens = (actualUsage as any)?.prompt_tokens_details?.cached_tokens || 0;
totalInputTokens += iterationInputTokens;
totalOutputTokens += iterationOutputTokens;
// Calculate cost if pricing is available (handles cached token discount)
if (pricing && (iterationInputTokens > 0 || iterationOutputTokens > 0)) {
totalCost += calculateCost(iterationInputTokens, iterationOutputTokens, pricing, iterationCachedTokens);
}
} catch (err) {
// If aborted during streaming, return gracefully
if (abortSignal?.aborted || (err instanceof Error && (err.name === 'AbortError' || err.message === 'Operation aborted'))) {
logger.debug('Sub-agent aborted during streaming');
return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
}
throw err;
}
const message = assistantMessage;
if (!message) break;
// Check for tool calls
if (hasToolCalls && assistantMessage.tool_calls.length > 0) {
// Clean up empty tool_calls entries (from sparse array)
assistantMessage.tool_calls = assistantMessage.tool_calls.filter(Boolean);
// Filter out tool calls with malformed JSON arguments (can happen if stream aborted mid-tool-call)
assistantMessage.tool_calls = assistantMessage.tool_calls.filter((tc: any) => {
const args = tc.function?.arguments;
if (!args) return true; // No args is valid
try {
JSON.parse(args);
return true;
} catch {
logger.warn('Filtering out sub-agent tool call with malformed JSON', {
tool: tc.function?.name,
argsPreview: args.slice(0, 100),
});
return false;
}
});
// Only add message if we have valid tool calls
if (assistantMessage.tool_calls.length === 0) {
hasToolCalls = false;
} else {
messages.push(message as any);
}
for (const toolCall of assistantMessage.tool_calls) {
// Check abort between tool calls
if (abortSignal?.aborted) {
return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
}
const { name, arguments: argsStr } = toolCall.function;
let args: Record<string, unknown>;
try {
args = JSON.parse(argsStr);
} catch {
args = {};
}
logger.debug(`Sub-agent tool call: ${name}`, { args });
onProgress?.({ tool: name, status: 'running', iteration: i, args });
try {
const result = await handleToolCall(name, args, { sessionId: subAgentSessionId, abortSignal });
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result,
} as any);
onProgress?.({ tool: name, status: 'done', iteration: i });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: `Error: ${msg}`,
} as any);
onProgress?.({ tool: name, status: 'error', iteration: i });
}
}
continue;
}
// Plain text response — we're done
if (message.content) {
messages.push({
role: 'assistant',
content: message.content,
});
return { response: message.content, usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
}
// The model produced an empty text response (e.g. it only called tools
// and issued no final summary). Log it and return a sentinel so the
// parent agent knows the sub-agent finished but had nothing to say.
logger.debug('Sub-agent returned empty content', { iteration: i });
return { response: '(sub-agent completed with no response)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
}
return { response: '(sub-agent reached iteration limit)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
} finally {
op.end();
clearTodos(subAgentSessionId);
}
}Step 2: Update src/tools/index.ts
Add abortSignal to ToolCallContext so tools can honor cancellation when a sub-agent is aborted:
export interface ToolCallContext {
sessionId?: string;
abortSignal?: AbortSignal;
}Step 3: Update src/agentic-loop.ts
Import the sub-agent tool and types, add sub_agent_iteration event type, and wire up special handling for sub_agent tool calls.
Add the import at the top:
import { subAgentTool, runSubAgent, type SubAgentProgressHandler, type SubAgentUsage } from './sub-agent.js';Update AgentEvent to include sub-agent progress and usage:
export interface AgentEvent {
type: 'text_delta' | 'tool_call' | 'tool_result' | 'usage' | 'error' | 'done' | 'iteration_done' | 'sub_agent_iteration';
content?: string;
toolCall?: ToolCallEvent;
usage?: { inputTokens: number; outputTokens: number; cost: number; contextPercent: number };
error?: string;
transient?: boolean;
subAgentTool?: { tool: string; status: 'running' | 'done' | 'error'; iteration: number; args?: Record<string, unknown> };
subAgentUsage?: SubAgentUsage;
}Include subAgentTool in the tools list sent to the API:
const allTools = [...getAllTools(), subAgentTool];In your tool execution section (above where handleToolCall is called), add special handling for sub_agent:
let result: string;
// Handle sub-agent tool specially
if (name === 'sub_agent') {
const subProgress: SubAgentProgressHandler = (evt) => {
onEvent({
type: 'sub_agent_iteration',
subAgentTool: { tool: evt.tool, status: evt.status, iteration: evt.iteration, args: evt.args },
});
};
const subResult = await runSubAgent(
client,
model,
args.task,
args.max_iterations,
requestDefaults,
subProgress,
abortSignal,
pricing,
);
result = subResult.response;
// Emit sub-agent usage for the UI to add to total cost
if (subResult.usage.inputTokens > 0 || subResult.usage.outputTokens > 0) {
onEvent({
type: 'sub_agent_iteration',
subAgentUsage: subResult.usage,
});
}
} else {
result = await handleToolCall(name, args, { sessionId, abortSignal });
}Also update the App.tsx to use the correct field name for cost:
// Handle sub-agent usage update
if (event.subAgentUsage) {
setTotalCost((prev) => prev + event.subAgentUsage!.estimatedCost);
}Verification
npm run devTry a prompt that benefits from delegation:
Investigate how the config system works in this project using a sub-agent and summarize the flow.You should see:
- A
sub_agenttool call in the parent conversation - The spinner briefly showing
sub_agent → bash(or whichever tool the sub-agent is using) — these aresub_agent_iterationevents that update the spinner without adding entries to the parent's message history - Only the summary returned to the parent transcript
█▀█ █▀█ █▀█ ▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀
█▀▀ █▀▄ █▄█ █ █▄█ █▀█ █▄█ ██▄ █ ▀█ █
Model: OpenAI / gpt-5-mini
[System prompt loaded]
> use a subagent to understand this codebase
Tool: todo_write({"todos":[{"id":"analyze-subagent","content":"Spawn sub-agent to
analyze the codebase: list files, r)
TODO List Updated (2 items):
[~] [high] Spawn sub-agent to analyze the codebase: list files, read package.json
and AGENTS.md, scan src/, summarize each module, identify entry points, build/test
script...
Tool: sub_agent({"task":"Analyze the TypeScript codebase in
/Users/thomasgauvin/work-in-progress/2025/protoagent/pro)
BEEP BEEP
{
"files_listed": {
"root": [
".env",
".protoagent",
"agents.md",
"dist",
"node_modules",
"package-lock.json",
"package.json",
"src",
...
BEEP BEEP
✅ Sub-agent completed analysis.
Summary (concise):
- I spawned a sub-agent that listed files, read package.json, AGENTS.md,
tsconfig.json, and key source files.
- The sub-agent produced per-file summaries, identified entry points (src/cli.tsx
and dist/cli.js), listed scripts, and proposed follow-up tasks.
Key findings:
- package.json scripts: "build" (tsc), "dev" (tsx src/cli.tsx), "build:watch". No
"test" script despite AGENTS.md recommending npm test.
- TypeScript config: strict mode enabled, NodeNext module resolution, outDir dist,
rootDir src.
- Main entry: src/cli.tsx (dev) and compiled dist/cli.js (bin).
- Notable modules: agentic-loop (core streaming logic), tools/index (tool registry),
skills (dynamic skill discovery), mcp (Model Context Protocol integration),
sub-agent (spawns isolated agents), App.tsx (Ink UI).
- Several modules silently swallow errors (skills, mcp, session load) — add
logging/diagnostics.
- Tests and CI are missing; add unit tests for streaming/tool-calls and
runtime-config.
Next recommended tasks (pick one and I'll proceed):
- Add a test script and initial unit tests (I can scaffold tests for agentic-loop or
tools).
- Improve error logging in skills loading and MCP initialization.
- Create README and document build/test workflow.
- Run project build (npm run build) to confirm compile status.
Which follow-up task do you want me to take next?
tokens: 7373↓ 352↑ | ctx: 1% | cost: $0.0043
Session: 1h32x5xy
╭────────────────────────────────────────────────────╮
│ > Type your message... │
╰────────────────────────────────────────────────────╯Resulting snapshot
Your project should match protoagent-build-your-own-checkpoints/part-12.
Core takeaway
Sub-agents keep the main conversation clean by running noisy investigation work in an isolated context. The parent gets a focused summary instead of hundreds of intermediate tool calls polluting its history.