diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 53f6554a..e5cc579f 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -257,12 +257,17 @@ class AgentRunner: return [] injected_messages: list[dict[str, Any]] = [] for item in items: - if isinstance(item, dict) and item.get("role") == "user" and "content" in item: - injected_messages.append(item) + if item is None: continue - text = getattr(item, "content", str(item)) - if text.strip(): - injected_messages.append({"role": "user", "content": text}) + if isinstance(item, dict) and item.get("role") == "user" and "content" in item: + if self._has_injection_content(item.get("content")): + injected_messages.append(item) + continue + if isinstance(item, dict): + continue + content = getattr(item, "content") if hasattr(item, "content") else str(item) + if self._has_injection_content(content): + injected_messages.append({"role": "user", "content": content}) if len(injected_messages) > _MAX_INJECTIONS_PER_TURN: dropped = len(injected_messages) - _MAX_INJECTIONS_PER_TURN logger.warning( @@ -272,6 +277,16 @@ class AgentRunner: injected_messages = injected_messages[:_MAX_INJECTIONS_PER_TURN] return injected_messages + @staticmethod + def _has_injection_content(content: Any) -> bool: + if content is None: + return False + if isinstance(content, str): + return bool(content.strip()) + if isinstance(content, list): + return bool(content) + return True + async def run(self, spec: AgentRunSpec) -> AgentRunResult: hook = spec.hook or AgentHook() messages = list(spec.initial_messages) diff --git a/tests/agent/test_runner_injections.py b/tests/agent/test_runner_injections.py index 3b94569d..637235ba 100644 --- a/tests/agent/test_runner_injections.py +++ b/tests/agent/test_runner_injections.py @@ -152,6 +152,70 @@ async def test_drain_injections_skips_empty_content(): assert result == [{"role": "user", "content": "valid"}] +@pytest.mark.asyncio +async def test_drain_injections_filters_empty_dict_payloads(): + """Pre-normalized dict injections should obey the same empty-content guard.""" + from nanobot.agent.runner import AgentRunner, AgentRunSpec + + provider = MagicMock() + runner = AgentRunner(provider) + tools = MagicMock() + tools.get_definitions.return_value = [] + + multimodal = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}] + msgs = [ + {"role": "user", "content": ""}, + {"role": "user", "content": " "}, + {"role": "user", "content": None}, + {"role": "assistant", "content": "should not be re-injected as user"}, + None, + {"role": "user", "content": "valid"}, + {"role": "user", "content": multimodal}, + ] + + async def cb(): + return msgs + + spec = AgentRunSpec( + initial_messages=[], tools=tools, model="m", + max_iterations=1, max_tool_result_chars=1000, + injection_callback=cb, + ) + result = await runner._drain_injections(spec) + assert result == [ + {"role": "user", "content": "valid"}, + {"role": "user", "content": multimodal}, + ] + + +@pytest.mark.asyncio +async def test_drain_injections_skips_objects_with_none_content(): + """Objects exposing content=None should be skipped rather than stringified.""" + from types import SimpleNamespace + + from nanobot.agent.runner import AgentRunner, AgentRunSpec + + provider = MagicMock() + runner = AgentRunner(provider) + tools = MagicMock() + tools.get_definitions.return_value = [] + + async def cb(): + return [ + SimpleNamespace(content=None), + SimpleNamespace(content=""), + SimpleNamespace(content="valid"), + ] + + spec = AgentRunSpec( + initial_messages=[], tools=tools, model="m", + max_iterations=1, max_tool_result_chars=1000, + injection_callback=cb, + ) + result = await runner._drain_injections(spec) + assert result == [{"role": "user", "content": "valid"}] + + @pytest.mark.asyncio async def test_drain_injections_handles_callback_exception(): """If the callback raises, return empty list (error is logged).""" @@ -1155,4 +1219,3 @@ async def test_injection_cycle_cap_on_error_path(): assert result.had_injections is True # Should cap: _MAX_INJECTION_CYCLES drained rounds + 1 final round that breaks assert call_count["n"] == _MAX_INJECTION_CYCLES + 1 -