Problem
Two distinct defects break the agent-chat HITL approval flow on local Ollama (agent_default_model=ollama:*), discovered while dogfooding the Chat page.
Bug 1 — null-content crash (FallbackExceptionGroup)
Ollama's OpenAI-compatible /v1/chat/completions rejects any message whose content is JSON null and which carries no tool_calls with 400 invalid message content type: <nil> (stricter than the real OpenAI API). A weak local model can emit a degenerate empty assistant turn (no text, no tool call); PydanticAI serialises it as content: null and then replays that message on its validation-retry, so every retry 400s and the whole run dies with FallbackExceptionGroup (both primary + fallback). Not streaming-specific — hits agent.run() too (any multi-step/tool turn).
Verified truth table (curl → forecastlab-ollama, qwen3:8b):
assistant content=null + tool_calls → 200 ✅
assistant content=null no tool_calls → 400 ❌ invalid message content type: <nil>
assistant content="" no tool_calls → 200 ✅
Bug 2 — pending approval dropped on model misbehavior
A gated tool (create_alias/archive_run/save_scenario) sets deps.pending_action the moment it fires (the HITL gate works), but it does NOT halt the run. A weak model rambles past the gate, calls more tools, and exhausts its retry budget → UnexpectedModelBehavior is raised before agent.run() returns. AgentService.stream_chat / chat's except UnexpectedModelBehavior handler then emits a generic "invalid tool call" error and returns without checking deps.pending_action — so the already-captured approval (with valid args) is silently discarded and the Approve card never appears.
Fix
- Bug 1: inject a sanitizing
httpx.AsyncClient into OllamaProvider(http_client=…) in build_agent_model (app/features/agents/agents/base.py) that coerces outgoing messages[*].content: null → "".
- Bug 2: in the
UnexpectedModelBehavior handlers of both stream_chat and chat, surface deps.pending_action (persist + emit approval_required / return pending_approval=True) before falling back to the generic error. The gate fired — the approval is valid regardless of post-gate model misbehavior.
Acceptance
- Outgoing Ollama chat requests never send
content: null.
- When a gated tool fires but the model then misbehaves, the HITL Approve card is surfaced (not a generic error).
- Existing "invalid tool call" behavior preserved when NO gated tool fired.
Problem
Two distinct defects break the agent-chat HITL approval flow on local Ollama (
agent_default_model=ollama:*), discovered while dogfooding the Chat page.Bug 1 — null-content crash (
FallbackExceptionGroup)Ollama's OpenAI-compatible
/v1/chat/completionsrejects any message whosecontentis JSONnulland which carries notool_callswith400 invalid message content type: <nil>(stricter than the real OpenAI API). A weak local model can emit a degenerate empty assistant turn (no text, no tool call); PydanticAI serialises it ascontent: nulland then replays that message on its validation-retry, so every retry 400s and the whole run dies withFallbackExceptionGroup(both primary + fallback). Not streaming-specific — hitsagent.run()too (any multi-step/tool turn).Verified truth table (curl →
forecastlab-ollama, qwen3:8b):assistant content=null+ tool_calls → 200 ✅assistant content=nullno tool_calls → 400 ❌invalid message content type: <nil>assistant content=""no tool_calls → 200 ✅Bug 2 — pending approval dropped on model misbehavior
A gated tool (
create_alias/archive_run/save_scenario) setsdeps.pending_actionthe moment it fires (the HITL gate works), but it does NOT halt the run. A weak model rambles past the gate, calls more tools, and exhausts its retry budget →UnexpectedModelBehavioris raised beforeagent.run()returns.AgentService.stream_chat/chat'sexcept UnexpectedModelBehaviorhandler then emits a generic "invalid tool call" error and returns without checkingdeps.pending_action— so the already-captured approval (with valid args) is silently discarded and the Approve card never appears.Fix
httpx.AsyncClientintoOllamaProvider(http_client=…)inbuild_agent_model(app/features/agents/agents/base.py) that coerces outgoingmessages[*].content: null → "".UnexpectedModelBehaviorhandlers of bothstream_chatandchat, surfacedeps.pending_action(persist + emitapproval_required/ returnpending_approval=True) before falling back to the generic error. The gate fired — the approval is valid regardless of post-gate model misbehavior.Acceptance
content: null.