Build your own Claude Code
Subagents·30 min

The Task tool

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage the-task-tool --watch

Now you spawn one. runSubagent takes a parsed AgentDef, a prompt, a model, and the parent's base execute, and runs a complete agent loop in isolation — then returns only the final assistant text. This is the Task tool. It's runLoop from Chapter 1, started fresh and wrapped in a tool guard that enforces the agent's allowlist.

Three things must be true, and the grader checks all three: the child starts with empty context, a disallowed tool is rejected before it runs, and the parent gets back only the final text.

Empty context, own allowlist

The subagent must start with a fresh message history — [{ role: 'user', content: [{ type: 'text', text: prompt }] }] and nothing else. No parent turns leak in. And its tool calls are graded against its own allowlist (effectiveTools(def, allTools)), not the parent's: a Bash-banned agent that emits a Bash tool_use must have that call rejected before baseExecute ever sees it.

What to build

Your task

Implement runSubagent

In src/subagents.ts, implement runSubagent(def, prompt, model, baseExecute, allTools).

Compute the allowed set with effectiveTools(def, allTools). Wrap baseExecute in a guard execute: if the tool name isn't allowed, record it in blockedTools and return { content: "tool '<name>' is not permitted for agent '<def.name>'", is_error: true } without calling baseExecute; otherwise delegate. Build a fresh messages array from prompt, run runLoop(messages, model, execute, def.maxTurns ?? 10), and return { finalText, trajectory, blockedTools }.

src/subagents.ts
export interface SubagentResult {
  finalText: string;
  trajectory: { tool: string; input: Record<string, unknown> }[];
  blockedTools: string[];
}

/** Run a subagent: fresh context, own allowlist (disallowed tools rejected
 *  before they run), returns only the final assistant text. */
export async function runSubagent(
  def: AgentDef,
  prompt: string,
  model: Model,
  baseExecute: Execute,
  allTools: string[],
): Promise<SubagentResult> {
  const allowed = new Set(effectiveTools(def, allTools));
  const blockedTools: string[] = [];
  const execute: Execute = (name, input) => {
    // if !allowed.has(name): push to blockedTools, return an is_error result
    // else: return baseExecute(name, input)
  };
  // fresh context: messages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }]
  // const r = await runLoop(messages, model, execute, def.maxTurns ?? 10)
  // return { finalText: r.finalText, trajectory: r.trajectory, blockedTools }
}
The guard returns an error result, it does not throw

Rejecting a tool isn't an exception — it's a normal tool_result with is_error: true. The child's loop threads it back as the next turn, so the model sees "that tool isn't permitted" and can recover or stop. That's why blockedTools is collected separately: it records what was attempted and refused, while the loop carries on. In the grader's cassette the agent tries Bash (denied), and the run still ends cleanly at "found 2 errors".

How it's graded

process + cassette. The grader runs program.ts subagent: it builds a sandbox from files, parses agentMd, replays a recorded model with makeCassetteModel, and prints { finalText, trajectory, blockedTools }. The cassette scripts Grep (allowed), then Bash (denied), then a final text. Your output must match the reference exactly: finalText is the last assistant text only, trajectory lists both attempted tool calls in order, and blockedTools is ["Bash"].

How the grader runs it
./your_program.sh subagent
# agentMd: Explore, tools:[Grep], disallowedTools:[Bash]
# cassette: Grep(log.txt) → Bash(rm x) → "found 2 errors"
# -> { "finalText": "found 2 errors",
#      "trajectory": [ {Grep…}, {Bash…} ],
#      "blockedTools": ["Bash"] }