diff --git a/extensions/python/monologue_end/_85_ensure_response_log.py b/extensions/python/monologue_end/_85_ensure_response_log.py new file mode 100644 index 0000000000..dafd195181 --- /dev/null +++ b/extensions/python/monologue_end/_85_ensure_response_log.py @@ -0,0 +1,75 @@ +import json +from typing import Any + +from helpers.extension import Extension + + +class EnsureResponseLog(Extension): + async def execute(self, loop_data: Any = None, **kwargs): + if not self.agent: + return + + if not loop_data: + return + + if "log_item_response" in loop_data.params_temporary: + return + + gen_item = loop_data.params_temporary.get("log_item_generating") + if not gen_item: + return + + response_text = self._get_latest_ai_response_text() + if not response_text: + return + + loop_data.params_temporary["log_item_response"] = self.agent.context.log.log( + type="response", + heading=f"icon://chat {self.agent.agent_name}: Responding", + content=response_text, + id=getattr(gen_item, "id", "") or "", + ) + + def _get_latest_ai_response_text(self) -> str: + try: + topic = self.agent.history.current if self.agent else None + messages = topic.messages if topic else [] + for message in reversed(messages): + if message.ai and message.content: + return self._normalize_response_content(message.content) + return "" + except Exception: + return "" + + @staticmethod + def _normalize_response_content(content) -> str: + if isinstance(content, dict): + tool_name = content.get("tool_name") + if tool_name and tool_name != "response": + return "" + + if isinstance(content.get("tool_args"), dict): + text = content["tool_args"].get("text") + if text: + return str(text) + + if content.get("text"): + return str(content["text"]) + + if tool_name == "response": + return "" + + return str(content) + + if not isinstance(content, str): + return str(content) + + stripped = content.strip() + try: + parsed = json.loads(stripped) + except Exception: + return stripped + + if isinstance(parsed, dict): + return EnsureResponseLog._normalize_response_content(parsed) + return stripped diff --git a/tests/test_response_log_fallback.py b/tests/test_response_log_fallback.py new file mode 100644 index 0000000000..4fb657bad2 --- /dev/null +++ b/tests/test_response_log_fallback.py @@ -0,0 +1,116 @@ +from types import SimpleNamespace + +import pytest + +from extensions.python.monologue_end._85_ensure_response_log import EnsureResponseLog + + +class _LoopData: + def __init__(self, **kwargs): + self.params_temporary = kwargs.get("params_temporary", {}) + + +class FakeLog: + def __init__(self): + self.entries = [] + + def log(self, **kwargs): + self.entries.append(kwargs) + return SimpleNamespace(id=kwargs.get("id")) + + +def _agent_with_last_message(content, *, ai=True): + return _agent_with_messages([SimpleNamespace(ai=ai, content=content)]) + + +def _agent_with_messages(messages): + log = FakeLog() + agent = SimpleNamespace( + agent_name="A0", + context=SimpleNamespace(log=log), + history=SimpleNamespace( + current=SimpleNamespace(messages=messages), + ), + ) + return agent, log + + +@pytest.mark.asyncio +async def test_monologue_end_logs_plain_text_ai_response_from_history(): + agent, log = _agent_with_last_message("Plain text final answer.") + loop_data = _LoopData( + params_temporary={ + "log_item_generating": SimpleNamespace(id="stream-log-id"), + } + ) + + await EnsureResponseLog(agent=agent).execute(loop_data=loop_data) + + assert log.entries == [ + { + "type": "response", + "heading": "icon://chat A0: Responding", + "content": "Plain text final answer.", + "id": "stream-log-id", + } + ] + + +@pytest.mark.asyncio +async def test_monologue_end_logs_latest_ai_response_when_warning_follows_it(): + agent, log = _agent_with_messages( + [ + SimpleNamespace(ai=True, content="Plain text final answer."), + SimpleNamespace( + ai=False, + content="You have misformatted your message. Follow system prompt instructions.", + ), + ] + ) + loop_data = _LoopData( + params_temporary={ + "log_item_generating": SimpleNamespace(id="stream-log-id"), + } + ) + + await EnsureResponseLog(agent=agent).execute(loop_data=loop_data) + + assert log.entries == [ + { + "type": "response", + "heading": "icon://chat A0: Responding", + "content": "Plain text final answer.", + "id": "stream-log-id", + } + ] + + +@pytest.mark.asyncio +async def test_monologue_end_skips_when_live_response_already_logged(): + agent, log = _agent_with_last_message("Already logged.") + loop_data = _LoopData( + params_temporary={ + "log_item_generating": SimpleNamespace(id="stream-log-id"), + "log_item_response": SimpleNamespace(id="response-log-id"), + } + ) + + await EnsureResponseLog(agent=agent).execute(loop_data=loop_data) + + assert log.entries == [] + + +@pytest.mark.asyncio +async def test_monologue_end_does_not_log_non_response_tool_request(): + agent, log = _agent_with_last_message( + '{"tool_name":"search_engine","tool_args":{"query":"Agent Zero"}}' + ) + loop_data = _LoopData( + params_temporary={ + "log_item_generating": SimpleNamespace(id="stream-log-id"), + } + ) + + await EnsureResponseLog(agent=agent).execute(loop_data=loop_data) + + assert log.entries == []