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_argsis 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.