feat(engine): claude_agent_sdk adapter so OAuth users can use sonnet/opus - #7
Conversation
Quiz on your PRTake it: Question 1 — mcqIn
Question 2 — tfIf the agent stream completes without calling the MCP tool,
Question 3 — mcq
Question 4 — tfIn
Question 5 — openThe adapter sets Question 6 — mermaidWhich sequence diagram correctly shows how Option AsequenceDiagram
participant Caller
participant IT as _invoke_tool
participant DA as _drain_agent
participant Q as query()
participant MH as MCP handler
Caller->>IT: _invoke_tool()
IT->>IT: build handler, create options
IT->>DA: _drain_agent(prompt, options, handler)
DA->>DA: delete handler
DA->>Q: asyncio.run(_drain())
Q-->>DA: drain complete
DA->>MH: handler(args)
MH-->>DA: ok response
DA-->>IT: return
IT-->>Caller: captured[0]
Option BsequenceDiagram
participant Caller
participant IT as _invoke_tool
participant DA as _drain_agent
participant Q as query()
participant MH as MCP handler
Caller->>IT: _invoke_tool()
IT->>IT: build handler, create options
IT->>DA: _drain_agent(prompt, options, handler)
DA->>DA: delete handler
DA->>Q: asyncio.run(_drain())
Q-->>DA: drain complete
DA-->>IT: return
IT->>MH: handler(args)
MH-->>IT: ok response
IT-->>Caller: captured[0]
Option CsequenceDiagram
participant Caller
participant IT as _invoke_tool
participant DA as _drain_agent
participant Q as query()
Caller->>IT: _invoke_tool()
IT->>IT: build handler, create options
IT->>DA: _drain_agent(prompt, options, handler)
DA->>DA: delete handler
DA->>Q: asyncio.run(_drain())
Q->>Q: process tool call
Q-->>DA: tool args
DA->>DA: append to captured
DA-->>IT: return
IT-->>Caller: captured[0]
Option DsequenceDiagram
participant Caller
participant IT as _invoke_tool
participant DA as _drain_agent
participant Q as query()
participant MH as MCP handler
Caller->>IT: _invoke_tool()
IT->>IT: build handler, create options
IT->>DA: _drain_agent(prompt, options, handler)
DA->>DA: delete handler
DA->>Q: asyncio.run(_drain())
Q->>MH: handler(args)
MH-->>Q: ok response
Q-->>DA: drain complete
DA-->>IT: return
IT-->>Caller: captured[0]
Quiz state (used by the CLI){
"version": "1",
"pr_number": 7,
"questions": [
{
"type": "mcq",
"id": "q1",
"prompt": "In `llm_claude_agent.py`, `_drain_agent` immediately executes `del handler`. If production never uses that parameter, why does `_drain_agent` accept a `handler` argument at all?",
"options": [
"So tests can override `_drain_agent` with a fake that calls `handler(args)` directly, injecting canned tool args without spawning a subprocess.",
"So the production code can invoke `handler` if the agent's tool-call result arrives out-of-band, after the async stream has already closed.",
"So `_invoke_tool` can pass the handler into `ClaudeAgentOptions` for the SDK to invoke during its internal permission checks.",
"It is a forward-compatibility hook for a planned retry path that will call `handler` a second time with a tighter prompt."
],
"answer": "So tests can override `_drain_agent` with a fake that calls `handler(args)` directly, injecting canned tool args without spawning a subprocess."
},
{
"type": "tf",
"id": "q2",
"prompt": "If the agent stream completes without calling the MCP tool, `_invoke_tool` retries the request once with a tighter prompt before giving up and returning `None`.",
"answer": false
},
{
"type": "mcq",
"id": "q3",
"prompt": "`_invoke_tool` catches `CLINotFoundError`, `CLIConnectionError`, `ProcessError`, and `ClaudeSDKError` by name, then has a bare `except Exception` catch-all. What real failure does that last clause catch that the earlier ones miss?",
"options": [
"The SDK raises a bare `Exception` (not a SDK subclass) for protocol-level errors such as 'max turns reached'.",
"It catches `asyncio.CancelledError` when the event loop is shut down before the async stream finishes.",
"It catches `pydantic.ValidationError` raised by `ClaudeAgentOptions` if the model name is unrecognised.",
"It catches `KeyboardInterrupt` forwarded from the claude subprocess when the user hits Ctrl-C."
],
"answer": "The SDK raises a bare `Exception` (not a SDK subclass) for protocol-level errors such as 'max turns reached'."
},
{
"type": "tf",
"id": "q4",
"prompt": "In `take.py`, setting the environment variable `ANTHROPIC_API_KEY` to an empty string (`\"\"`) causes `_make_llm` to return an `AnthropicLLM` instance.",
"answer": false
},
{
"type": "open",
"id": "q5",
"prompt": "The adapter sets `allowed_tools=[\"mcp__quizz__<tool_name>\"]` with exactly one entry. Explain what problem this solves and why it works as a forcing mechanism for tool use.",
"rubric": "A complete answer must make all three of these claims: (1) `claude_agent_sdk` exposes no `tool_choice='required'` parameter, so there is no direct way to mandate that the agent must call a specific tool; (2) restricting `allowed_tools` to a single entry means the model's only permitted action besides finishing is to call that one tool, making tool invocation the only reachable path and preventing plain-text responses; (3) this matters because `_invoke_tool` returns only the captured tool-call args — a plain-text response leaves `captured` empty, causing `_invoke_tool` to return `None`, and callers immediately raise `RuntimeError`."
},
{
"type": "mermaid",
"id": "q6",
"prompt": "Which sequence diagram correctly shows how `_invoke_tool` orchestrates a production tool call, from the initial invocation through to capturing the structured tool arguments?",
"options": {
"A": "sequenceDiagram\n participant Caller\n participant IT as _invoke_tool\n participant DA as _drain_agent\n participant Q as query()\n participant MH as MCP handler\n Caller->>IT: _invoke_tool()\n IT->>IT: build handler, create options\n IT->>DA: _drain_agent(prompt, options, handler)\n DA->>DA: delete handler\n DA->>Q: asyncio.run(_drain())\n Q-->>DA: drain complete\n DA->>MH: handler(args)\n MH-->>DA: ok response\n DA-->>IT: return\n IT-->>Caller: captured[0]",
"B": "sequenceDiagram\n participant Caller\n participant IT as _invoke_tool\n participant DA as _drain_agent\n participant Q as query()\n participant MH as MCP handler\n Caller->>IT: _invoke_tool()\n IT->>IT: build handler, create options\n IT->>DA: _drain_agent(prompt, options, handler)\n DA->>DA: delete handler\n DA->>Q: asyncio.run(_drain())\n Q-->>DA: drain complete\n DA-->>IT: return\n IT->>MH: handler(args)\n MH-->>IT: ok response\n IT-->>Caller: captured[0]",
"C": "sequenceDiagram\n participant Caller\n participant IT as _invoke_tool\n participant DA as _drain_agent\n participant Q as query()\n Caller->>IT: _invoke_tool()\n IT->>IT: build handler, create options\n IT->>DA: _drain_agent(prompt, options, handler)\n DA->>DA: delete handler\n DA->>Q: asyncio.run(_drain())\n Q->>Q: process tool call\n Q-->>DA: tool args\n DA->>DA: append to captured\n DA-->>IT: return\n IT-->>Caller: captured[0]",
"D": "sequenceDiagram\n participant Caller\n participant IT as _invoke_tool\n participant DA as _drain_agent\n participant Q as query()\n participant MH as MCP handler\n Caller->>IT: _invoke_tool()\n IT->>IT: build handler, create options\n IT->>DA: _drain_agent(prompt, options, handler)\n DA->>DA: delete handler\n DA->>Q: asyncio.run(_drain())\n Q->>MH: handler(args)\n MH-->>Q: ok response\n Q-->>DA: drain complete\n DA-->>IT: return\n IT-->>Caller: captured[0]"
},
"answer": "D"
}
]
} |
Quiz resultsTotal: 50%
Results state (used by the CLI){
"version": "1",
"pr_number": 7,
"total_score": 50,
"per_question": [
{
"question_id": "q1",
"correct": true,
"score": 100,
"feedback": ""
},
{
"question_id": "q2",
"correct": true,
"score": 100,
"feedback": ""
},
{
"question_id": "q3",
"correct": false,
"score": 0,
"feedback": ""
},
{
"question_id": "q4",
"correct": true,
"score": 100,
"feedback": ""
},
{
"question_id": "q5",
"correct": false,
"score": 0,
"feedback": "The answer \"test\" is entirely non-responsive — it addresses none of the three required rubric items: the absence of a `tool_choice='required'` parameter in the SDK, the forcing mechanism created by restricting `allowed_tools` to a single entry, or the `None`/`RuntimeError` consequence of a plain-text response leaving `captured` empty."
},
{
"question_id": "q6",
"correct": false,
"score": 0,
"feedback": ""
}
]
} |
Adds a spec for routing quizz's inference through claude_agent_sdk (and the official `claude` binary) when ANTHROPIC_API_KEY is not set, so OAuth-only users on the Claude Code Max plan can use sonnet/opus instead of being gated to haiku by the api.anthropic.com OAuth-third-party-client policy.
Smoke test on PR #5 revealed two issues: - max_turns=2 was insufficient — the agent needs ~3 turns minimum (ToolSearch lookup → actual tool call → confirmation text). Bumping to 8 leaves headroom for thinking blocks. - claude_agent_sdk raises a bare `Exception` (not ClaudeSDKError) for protocol-level errors like "Reached maximum number of turns". Wrap it as RuntimeError so take.py's existing catch clause handles it uniformly with the other error types.
…n submits ClaudeAgentLLM._invoke_tool calls asyncio.run() inside _drain_agent. Called from cli/take.py (sync) that works fine, but /submit is an `async def` route running on uvicorn's event loop — Python forbids asyncio.run() from within a running loop, so every quiz with an OpenQuestion would 500 when the user submitted answers. Wrapping grade() in asyncio.to_thread moves the sync work off the event-loop thread and incidentally also keeps AnthropicLLM's blocking HTTP calls from pinning uvicorn. Includes a regression test that posts to /submit with a ClaudeAgentLLM whose _drain_agent uses the real asyncio.run pattern — failed before this commit, passes after.
956896a to
2594422
Compare
Summary
docs/superpowers/specs/2026-05-22-claude-agent-sdk-engine-design.md): direct calls toapi.anthropic.com/v1/messagesusing a Claude Code OAuthaccessTokenare gated by Anthropic to Haiku only. Sonnet/Opus instantly return a sparse 429rate_limit_errorwith body{"message": "Error"}and noanthropic-ratelimit-*headers — it's a policy gate, not a usage limit. Header-spoofing (x-app: cli,User-Agent: claude-cli/…,anthropic-client-platform: claude_code_cli,X-Claude-Code-Session-Id) does not unlock it. The officialclaudebinary works because it performs a session-binding handshake we can't replicate from outside.LLMClientadapter,ClaudeAgentLLM, that routes inference throughclaude_agent_sdk(which subprocesses theclaudebinary). Each method registers a one-shot in-process MCP tool to capture structured output.cli/take._make_llmbranches onANTHROPIC_API_KEY: present → directAnthropicLLM(unchanged), absent →ClaudeAgentLLM(new). The existingLLMClientProtocol,FakeLLM,generate.py,grade.py,server/app.py, and all existing tests are untouched.QuizOutlineobjects. The same calls previously hit the 429 gate in <1s.What changed
src/quizz/engine/llm_claude_agent.pysrc/quizz/cli/take.py_make_llmbranches onANTHROPIC_API_KEY;_generate_and_postexceptchain gains aRuntimeErrorclause for the agent-SDK error pathpyproject.tomlclaude-agent-sdk>=0.1.44tests/engine/test_llm_claude_agent.pytests/cli/test_take_select.pytests/cli/test_take.pyRuntimeErrorexit-1 pathdocs/superpowers/specs/…design.mddocs/superpowers/plans/…engine.mdNotable design choices
AnthropicLLMand hidesasyncio.runinternally. No ripple throughgenerate.py, server, or CLI tests.@tool. Each call builds a fresh in-process MCP server with one tool matching the existing schema (submit_quiz_outline,submit_mermaid_set,submit_grade). The handler stuffs the args into a closure-shared list; the adapter returns them as a Pydantic model. The MCP tool is allowlisted withallowed_tools=["mcp__quizz__<name>"]so the agent can't call anything else.max_turns=8. Empirically the agent needs ~3 turns (aToolSearchdiscovery call → the actual tool call → a confirmation text block). 8 gives headroom for thinking blocks without letting a stuck agent run away.CLINotFoundError→RuntimeError("claude binary not found; install Claude Code or set ANTHROPIC_API_KEY").CLIConnectionError/ProcessError/ClaudeSDKError→RuntimeError(f"claude agent SDK call failed: {e}"). The SDK also raises bareExceptionfor protocol errors like "max turns reached", so I wrap that too.take.py:_generate_and_postgets a singleRuntimeErrorclause that exits 1 with the message._drain_agenttest seam. Production's_drain_agent(prompt, options, handler)ignores thehandlerparam — the SDK fires it via MCP. Tests override_drain_agentand callhandlerdirectly to inject canned args without spawning a subprocess. Slight smell, smaller than the alternatives.Out of scope (deliberate)
ClaudeSDKClientacross a quiz (one subprocess instead of N). The per-call~1–2soverhead is acceptable next to the~5–15sLLM latency per call.Test plan
uv run pytest— 87 passed, 2 skippeduv run mypy src— clean (strict)uv run ruff check src tests+ruff format --check— cleangenerate_quiz_outlineagainst a 5-line synthetic diff withclaude-sonnet-4-6— returned 2 questions in 56sclaude-opus-4-7— returned 3 questions (incl. an OpenQuestion) in 33squizz takeend-to-end against a real PR (deferred — the full flow with 3-4 mermaid artisans serially can exceed 5min, but each per-call code path is exercised by the smoke tests above)🤖 Generated with Claude Code