Map Claude stop_reason to a typed agent loop outcome
Map stop_reason in the adapter to done, blocked or out_of_budget, resume on pause_turn, and throw on anything unexpected rather than reporting success.
A model's stop_reason says why one generation ended, not whether the task did. Map it in the adapter to the loop's own outcome: a finish call is done, end_turn without it is blocked, max_tokens is out_of_budget, refusal is blocked, pause_turn resumes, and anything unexpected throws, which the loop reports as failed.
import type { Message } from "@anthropic-ai/sdk/resources/messages";
type Stop =
| { kind: "done"; summary: string }
| { kind: "out_of_budget"; limit: "steps" | "tokens" | "wall_clock" | "tool_errors" }
| { kind: "blocked"; reason: string }
| { kind: "failed"; error: string };
type Mapped =
| { type: "call"; tool: string; args: unknown }
| { type: "halt"; stop: Stop }
| { type: "resume" }; // pause_turn: resend the content as-is, count a step
const halt = (stop: Stop): Mapped => ({ type: "halt", stop });
// Dispatch on stop_reason first, content second.
export function mapStopReason(res: Message): Mapped {
switch (res.stop_reason) {
case "tool_use": {
const uses = res.content.filter((b) => b.type === "tool_use");
const [use] = uses;
if (!use || uses.length > 1) throw new Error(`expected one tool_use, got ${uses.length}`);
if (use.name !== "finish") return { type: "call", tool: use.name, args: use.input };
const { summary } = use.input as Record<string, unknown>;
return halt({ kind: "done", summary: typeof summary === "string" ? summary : "" });
}
case "end_turn": // stopped without calling finish
return halt({ kind: "blocked", reason: "model stopped without finish" });
case "max_tokens": // may hold a truncated tool_use: never run it
case "model_context_window_exceeded":
return halt({ kind: "out_of_budget", limit: "tokens" });
case "refusal": // an HTTP 200, not an exception
return halt({ kind: "blocked", reason: "model refused" });
case "pause_turn":
return { type: "resume" };
default: // stop_sequence (none configured) or null
throw new Error(`unexpected stop_reason: ${res.stop_reason}`);
}
}Why does it work?
end_turnis not completion. It can arrive with empty content, particularly after tool results, so a loop that reads it as done reports success on nothing.doneis a claim; the gate reruns the acceptance check.stop_reasondecides before content does. Amax_tokensreply can end inside atool_useblock, which must never run. Setdisable_parallel_tool_use: trueintool_choice, so a second block is a defect.- A refusal is a normal HTTP 200, so no error handler sees it.
pause_turncomes only with server tools such as web search; the docs say to resend the response as-is.
When should you not use it?
- Other providers split finish reasons differently; each adapter needs its own table.
- For a spike watched in the editor, typed outcomes cost more than they return.