From 66063abb8cc6d79371bbfd3ae28c9c7a13784c6e Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Sat, 28 Feb 2026 00:57:08 -0300 Subject: [PATCH 1/2] fix: prevent session poisoning from null/error LLM responses When an LLM returns content: null on a plain assistant message (no tool_calls), the null gets saved to session history and causes permanent 400 errors on every subsequent request. - Sanitize None content on plain assistant messages to "(empty)" in _sanitize_empty_content(), matching the existing empty-string handling - Skip persisting error responses (finish_reason="error") to the message history in _run_agent_loop(), preventing poison loops Closes #1303 --- nanobot/agent/loop.py | 6 ++++++ nanobot/providers/base.py | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6fe37e9..6cd8e56 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -224,6 +224,12 @@ class AgentLoop: ) else: clean = self._strip_think(response.content) + # Don't persist error responses to session history — they can + # poison the context and cause permanent 400 loops (#1303). + if response.finish_reason == "error": + logger.error("LLM returned error: {}", (clean or "")[:200]) + final_content = clean or "Sorry, I encountered an error calling the AI model." + break messages = self.context.add_assistant_message( messages, clean, reasoning_content=response.reasoning_content, ) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index eb1599a..f52a951 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -51,6 +51,14 @@ class LLMProvider(ABC): for msg in messages: content = msg.get("content") + # None content on a plain assistant message (no tool_calls) crashes + # providers with "invalid message content type: ". + if content is None and msg.get("role") == "assistant" and not msg.get("tool_calls"): + clean = dict(msg) + clean["content"] = "(empty)" + result.append(clean) + continue + if isinstance(content, str) and not content: clean = dict(msg) clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)" From 0036116e0ba94b2b7a1889a570d0a345ddc538a3 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 28 Feb 2026 07:35:07 +0000 Subject: [PATCH 2/2] fix: filter empty assistant messages in _save_turn instead of patching at send time --- nanobot/agent/loop.py | 2 ++ nanobot/providers/base.py | 8 -------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6cd8e56..9bca0a2 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -451,6 +451,8 @@ class AgentLoop: for m in messages[skip:]: entry = {k: v for k, v in m.items() if k != "reasoning_content"} role, content = entry.get("role"), entry.get("content") + if role == "assistant" and not content and not entry.get("tool_calls"): + continue # skip empty assistant messages — they poison session context if role == "tool" and isinstance(content, str) and len(content) > self._TOOL_RESULT_MAX_CHARS: entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)" elif role == "user": diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index f52a951..eb1599a 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -51,14 +51,6 @@ class LLMProvider(ABC): for msg in messages: content = msg.get("content") - # None content on a plain assistant message (no tool_calls) crashes - # providers with "invalid message content type: ". - if content is None and msg.get("role") == "assistant" and not msg.get("tool_calls"): - clean = dict(msg) - clean["content"] = "(empty)" - result.append(clean) - continue - if isinstance(content, str) and not content: clean = dict(msg) clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)"