The Task tool
# grade this stage (from your course directory)
npx theinference test --stage the-task-tool --watchNow 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.
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
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 }.
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 }
}
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".
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"].
./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"] }