Phase 2 reference solution

The full loop. Two import changes at the top of agent.py: add import json, and extend the SDK types import to from openai.types.chat import ChatCompletionMessageParam, ChatCompletionToolUnionParam. One module-level helper:

def _replayable_args(raw: str | None) -> str:
    """Tool-call arguments as the provider will accept them back."""
    try:
        json.loads(raw or "{}")
    except json.JSONDecodeError:
        return "{}"
    return raw or "{}"

The body:

    result = LoopResult()
    messages: list[ChatCompletionMessageParam] = [
        {"role": "system", "content": system},
        {"role": "user", "content": user},
    ]
    for _ in range(max_rounds):
        resp = client.chat.completions.create(
            model=model,
            max_tokens=max_tokens,
            messages=messages,
            tools=tool_defs,
            temperature=0,
        )
        result.usage.add(resp.usage)
        result.rounds += 1
        msg = resp.choices[0].message
        calls = [tc for tc in (msg.tool_calls or []) if tc.type == "function"]

        # Feed the assistant turn back verbatim (content incl. <think>); the
        # exact bytes let the provider hit its implicit prompt cache.
        assistant: ChatCompletionMessageParam = {"role": "assistant", "content": msg.content or ""}
        if calls:
            assistant["tool_calls"] = [
                {
                    "id": tc.id,
                    "type": "function",
                    "function": {
                        "name": tc.function.name,
                        # Verbatim, except when the model emitted arguments that are
                        # not valid JSON: replaying those 400s the whole request, so
                        # send "{}" and let the tool report the bad call instead.
                        "arguments": _replayable_args(tc.function.arguments),
                    },
                }
                for tc in calls
            ]
        messages.append(assistant)

        if not calls:
            result.final = strip_think(msg.content or "")
            break

        for tc in calls:
            try:
                args = json.loads(tc.function.arguments or "{}")
            except json.JSONDecodeError:
                args = {}
            out = await run_tool(tc.function.name, args)
            messages.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(out)})
    return result

Why the shape is what it is:

  • The tc.type == "function" filter narrows the SDK’s union tool-call type - this is the pyright friction the page warns about, solved without a cast.
  • The assistant turn is rebuilt as a plain dict and appended verbatim (content including <think>) so the scripted LOOP-3 cache invariant and the provider’s implicit prompt cache both hold.
  • Malformed tool arguments fall back to {} per the docstring contract instead of crashing the loop - and _replayable_args is the same fallback applied to the replay, because “verbatim” stops being possible the moment the model emits arguments the provider will not accept back. Send {} and let the tool report the bad call; replaying the broken string 400s the entire request and kills the episode.

Verified: all LOOP scenarios pass, ruff check and pyright clean.