v1.20.13 — Gemini 3.x list-content fix
🔧 Fixed — Responses no longer fail or degrade with Gemini 3.x models
Gemini 3.x (gemini-3.5-flash, gemini-3-pro-preview) returns AIMessage.content as a list of content blocks ([{"type":"text","text":"…"}]) instead of a plain string like earlier Gemini and every other provider. Several code paths assumed str and broke:
- SSE token streaming aborted with a
ChatStreamChunkValidationError — surfacing to the user as "Un problème est survenu lors de la génération de la réponse" — in both Pipeline and ReAct modes (they share the response-node token stream). - ReAct mode also crashed in
format_conversation_history('list' object has no attribute 'strip') whenever the conversation history held Gemini list-content messages — independent of the response model (mixed-provider setups were equally affected). - Silent degradations: skipped psyche / registry-filtering / HTML-widget post-processing, garbled voice (TTS) output, distorted token counts, dropped tokens after a HITL resumption, email-draft generation, and the
structured_outputJSON-mode fallback.
♻️ Changed — Canonical content-normalization primitive (DRY)
New coerce_content_to_text() (infrastructure/llm/message_text.py) normalizes any .content shape (str, Gemini 3.x list[dict] blocks, None) to plain text, mirroring LangChain BaseMessage.text. Applied at the shared chokepoints (SSE token formatter, format_conversation_history, structured_output fallback) so all callers are protected; four pre-existing bespoke list → text implementations consolidated onto it. Non-destructive — messages stored in graph state are never mutated.
Tests: new helper + regression suites reproducing both crashes; Ruff / Black / MyPy clean; 8316 unit tests green.
Docs: docs/technical/LLM_PROVIDER_CONSTRAINTS.md.
No DB migration, no new env var, no endpoint change.
🤖 Generated with Claude Code