diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 19bed7d93..e5a04f6ae 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -933,14 +933,15 @@ class AgentRunner: kept = kept[i:] break else: - # No user message in the kept window — walk backwards through - # non_system to find the nearest user message and keep it plus - # everything after it. Providers like GLM reject requests - # where the first non-system message is not ``user`` (error 1214). + # Recover nearest user message from outside the kept window; + # GLM rejects system→assistant (error 1214). Budget is + # intentionally exceeded — oversized beats invalid. for idx in range(len(non_system) - 1, -1, -1): if non_system[idx].get("role") == "user": kept = non_system[idx:] break + # If no user exists at all, _enforce_role_alternation + # will insert a synthetic one as a safety net. start = find_legal_message_start(kept) if start: kept = kept[start:] diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index cb23c0d28..54ce56e4a 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -77,6 +77,9 @@ class GenerationSettings: reasoning_effort: str | None = None +_SYNTHETIC_USER_CONTENT = "(conversation continued)" + + class LLMProvider(ABC): """Base class for LLM providers.""" @@ -417,7 +420,7 @@ class LLMProvider(ABC): for i, msg in enumerate(merged): if msg.get("role") != "system": if msg.get("role") == "assistant" and not msg.get("tool_calls"): - merged.insert(i, {"role": "user", "content": "(conversation continued)"}) + merged.insert(i, {"role": "user", "content": _SYNTHETIC_USER_CONTENT}) break return merged diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index f890685c8..51d449a96 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -2909,3 +2909,12 @@ def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch): assert isinstance(trimmed, list) # Must have at least system. assert any(m.get("role") == "system" for m in trimmed) + # The _enforce_role_alternation safety net must be able to fix whatever + # _snip_history returns here — verify it produces a valid sequence. + from nanobot.providers.base import LLMProvider + fixed = LLMProvider._enforce_role_alternation(trimmed) + non_system = [m for m in fixed if m["role"] != "system"] + if non_system: + assert non_system[0]["role"] in ("user", "tool"), ( + f"Safety net should ensure first non-system is user/tool, got {non_system[0]['role']}" + ) diff --git a/tests/providers/test_enforce_role_alternation.py b/tests/providers/test_enforce_role_alternation.py index 3afb7c8ec..1195c258a 100644 --- a/tests/providers/test_enforce_role_alternation.py +++ b/tests/providers/test_enforce_role_alternation.py @@ -1,6 +1,6 @@ """Tests for LLMProvider._enforce_role_alternation.""" -from nanobot.providers.base import LLMProvider +from nanobot.providers.base import LLMProvider, _SYNTHETIC_USER_CONTENT class TestEnforceRoleAlternation: @@ -208,7 +208,7 @@ class TestEnforceRoleAlternation: result = LLMProvider._enforce_role_alternation(msgs) non_system = [m for m in result if m["role"] != "system"] assert non_system[0]["role"] == "user" - assert non_system[0]["content"] == "(conversation continued)" + assert non_system[0]["content"] == _SYNTHETIC_USER_CONTENT # The original assistant should follow. assert non_system[1]["role"] == "assistant"