Mustafa Nevfel

Snippet · TypeScript

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.

ts
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_turn is not completion. It can arrive with empty content, particularly after tool results, so a loop that reads it as done reports success on nothing. done is a claim; the gate reruns the acceptance check.
  • stop_reason decides before content does. A max_tokens reply can end inside a tool_use block, which must never run. Set disable_parallel_tool_use: true in tool_choice, so a second block is a defect.
  • A refusal is a normal HTTP 200, so no error handler sees it. pause_turn comes 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.