#!/usr/bin/env python3 """Generic Anthropic Messages API tool loop (client tools + server tools + pause_turn + programmatic tool calling). STATUS: LIVE_VERIFIED 2026-09-18 — run with `.venv/bin/python examples/shared/tool-loop/anthropic_tool_loop.py` (claude-haiku-4-5-20251001, two custom tools, one parallel round trip, ~1.5k tokens ≈ $0.002). Dependency-free: requests go through scripts/live.py (keys from .env, every call logged to reports/live-requests.jsonl). Contract implemented (see docs/tools/anthropic/tool-use-loop.md): * loop while stop_reason == "tool_use"; exit on end_turn / max_tokens / stop_sequence / refusal * stop_reason == "pause_turn" (server tools hit the iteration cap): re-send the assistant content as-is, same tools * run EVERY client tool_use of a turn (parallel calls), return one tool_result per id, all in ONE user message, tool_result blocks FIRST (text after them only when no server tool / programmatic call is pending) * a server_tool_use / mcp_tool_use with no matching *_tool_result in the same response is still pending: reply with tool_result blocks only, keep the same tools array (the API runs it on the next request) * tool_use.caller.type != "direct" => programmatic call from code_execution: reply must be tool_result-only and carry the response's container.id in the top-level `container` field * errors are returned to the model with is_error:true instead of raising """ from __future__ import annotations import json import sys from pathlib import Path from typing import Any, Callable ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT)) from scripts.live import anthropic_request # noqa: E402 MODEL = "claude-haiku-4-5-20251001" RESULT_SUFFIXES = ("_tool_result",) # web_search_tool_result, web_fetch_tool_result, code_execution_tool_result, mcp_tool_result, … def pending_server_calls(content: list[dict]) -> list[dict]: """server_tool_use / mcp_tool_use blocks whose result block is not in the same response.""" answered = {b.get("tool_use_id") for b in content if b.get("type", "").endswith(RESULT_SUFFIXES)} return [b for b in content if b.get("type") in ("server_tool_use", "mcp_tool_use") and b["id"] not in answered] def run_tool_loop(messages: list[dict], tools: list[dict], handlers: dict[str, Callable[[dict], Any]], *, model: str = MODEL, max_tokens: int = 300, beta: str | None = None, max_turns: int = 8, extra: dict | None = None) -> dict: container: str | None = None last: dict = {} for turn in range(max_turns): body: dict = {"model": model, "max_tokens": max_tokens, "tools": tools, "messages": messages, **(extra or {})} if container: body["container"] = container status, resp, _ = anthropic_request("POST", "/v1/messages", body, beta=beta, note=f"anthropic_tool_loop turn {turn}") if status != 200: raise RuntimeError(f"HTTP {status}: {json.dumps(resp)[:400]}") last = resp if resp.get("container"): container = resp["container"]["id"] # keep code-execution state across turns content = resp["content"] messages.append({"role": "assistant", "content": content}) stop = resp["stop_reason"] if stop == "pause_turn": continue # server-side loop paused: re-send as-is with the same tools if stop != "tool_use": return resp results: list[dict] = [] programmatic = False for block in content: if block.get("type") != "tool_use": continue caller = (block.get("caller") or {}).get("type", "direct") programmatic |= caller != "direct" handler = handlers.get(block["name"]) result: dict = {"type": "tool_result", "tool_use_id": block["id"]} if block.get("toolset_name"): # computer / browser toolset members must echo toolset_name result["toolset_name"] = block["toolset_name"] try: if handler is None: raise KeyError(f"no handler for tool {block['name']}") out = handler(block["input"]) result["content"] = out if isinstance(out, (str, list)) else json.dumps(out) except Exception as err: # noqa: BLE001 — report to the model, do not crash the loop result["content"] = f"Error: {err}" result["is_error"] = True results.append(result) if not results: # only server tools pending and nothing for us: re-send as-is continue # Programmatic calls and pending server calls: the user message must contain tool_result blocks ONLY. if pending_server_calls(content) or programmatic: messages.append({"role": "user", "content": results}) else: messages.append({"role": "user", "content": results}) # text could be appended AFTER results here return last if __name__ == "__main__": tools = [ {"name": "get_weather", "description": "Get the current weather for a city.", "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}, {"name": "get_time", "description": "Get the current local time in a city.", "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}, ] handlers = {"get_weather": lambda i: f"{i['location']}: 18 C, light rain", "get_time": lambda i: f"{i['location']}: 14:05"} msgs = [{"role": "user", "content": "Weather and local time in Tokyo? Use both tools, then answer in one short sentence."}] final = run_tool_loop(msgs, tools, handlers, max_tokens=200) print(final["stop_reason"], json.dumps([b.get("text") for b in final["content"] if b["type"] == "text"])) print("turns:", sum(1 for m in msgs if m["role"] == "assistant"), "usage:", final["usage"]["input_tokens"], "in /", final["usage"]["output_tokens"], "out")