#!/usr/bin/env python3 """Minimal, correct tool-use agentic loop on the Anthropic Messages API. The canonical pattern: define a tool, call messages.create, and keep looping while stop_reason == "tool_use" — execute each requested tool, append a tool_result, and re-request until stop_reason == "end_turn". Run: pip install anthropic (then: export ANTHROPIC_API_KEY=sk-...) python agentic-loop.py Copy this file and adapt the >>> ADAPT marks for your own tools. Reflects the current API (model claude-opus-4-8, typed content blocks). """ # The Anthropic SDK accepts plain dict literals for tools/messages at runtime # (as the official docs show), but its strict TypedDict stubs over-narrow them. # Silence those false positives so this starter stays readable; real apps may # prefer the SDK's typed params (anthropic.types.ToolParam, MessageParam). # pyright: reportArgumentType=false import anthropic client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment MODEL = "claude-opus-4-8" # >>> ADAPT: pick a tier (see the skill's model table) # --- 1. Define your tool(s) ------------------------------------------------ # The input_schema is JSON Schema. Write a description that says WHEN to call. TOOLS = [ { "name": "get_weather", # >>> ADAPT "description": "Get the current weather for a city. " "Call this whenever the user asks about weather conditions.", "input_schema": { "type": "object", "properties": { "location": {"type": "string", "description": "City, e.g. 'Paris'"}, }, "required": ["location"], }, } ] # --- 2. Implement each tool ------------------------------------------------ # Map tool name -> a Python callable. Never trust the arguments blindly: they # come from the model. Validate before doing anything with side effects. def get_weather(location: str) -> str: # >>> ADAPT: real implementation return f"It is 21°C and sunny in {location}." TOOL_IMPLS = {"get_weather": get_weather} def run_tool(name: str, tool_input: dict) -> str: """Dispatch a tool call, returning a string result for the model.""" impl = TOOL_IMPLS.get(name) if impl is None: return f"ERROR: unknown tool {name!r}" try: return impl(**tool_input) except Exception as exc: # surface failures back to the model, don't crash return f"ERROR running {name}: {exc}" # --- 3. The loop ----------------------------------------------------------- def agent(user_prompt: str, max_turns: int = 10) -> str: # The conversation is a growing list of message dicts we own and replay. messages = [{"role": "user", "content": user_prompt}] for _ in range(max_turns): response = client.messages.create( model=MODEL, max_tokens=4096, tools=TOOLS, messages=messages, ) # Append the assistant turn VERBATIM — content is a list of typed # blocks (text and/or tool_use). It must go back as-is next request. messages.append({"role": "assistant", "content": response.content}) # If the model didn't ask for a tool, we're done — return its text. if response.stop_reason != "tool_use": return "".join( block.text for block in response.content if block.type == "text" ) # Otherwise: execute EVERY tool_use block and collect tool_result # blocks (the model may request several tools in parallel). tool_results = [] for block in response.content: if block.type != "tool_use": continue # skip text/thinking blocks result_text = run_tool(block.name, block.input) tool_results.append({ "type": "tool_result", "tool_use_id": block.id, # MUST echo the matching id "content": result_text, # "is_error": True, # set when the tool failed }) # Feed results back as a single user turn, then loop to re-request. messages.append({"role": "user", "content": tool_results}) return "Stopped: hit max_turns without an end_turn." if __name__ == "__main__": answer = agent("What's the weather in Tokyo right now?") print(answer) # For debugging, inspect the assembled transcript: # print(json.dumps(..., indent=2, default=str))