From 6036355ac513b1ec8e5dccccfe3679e9290753d2 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 26 Apr 2026 12:05:13 +0000 Subject: [PATCH] fix(message): limit session recording to proactive sends Only mark message-tool deliveries for channel-session recording while cron jobs are running, avoiding duplicate session writes during normal user turns. Made-with: Cursor --- nanobot/agent/tools/message.py | 22 +++++++++++++++++++--- nanobot/cli/commands.py | 29 +++++++++++++++++++++++++---- tests/tools/test_message_tool.py | 21 +++++++++++++++++++++ 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index ee78df46..ea7f91bc 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -42,6 +42,10 @@ class MessageTool(Tool): default=default_message_id, ) self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False) + self._record_channel_delivery_var: ContextVar[bool] = ContextVar( + "message_record_channel_delivery", + default=False, + ) def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Set the current message context.""" @@ -57,6 +61,14 @@ class MessageTool(Tool): """Reset per-turn send tracking.""" self._sent_in_turn = False + def set_record_channel_delivery(self, active: bool): + """Mark tool-sent messages as proactive channel deliveries.""" + return self._record_channel_delivery_var.set(active) + + def reset_record_channel_delivery(self, token) -> None: + """Restore previous proactive delivery recording state.""" + self._record_channel_delivery_var.reset(token) + @property def _sent_in_turn(self) -> bool: return self._sent_in_turn_var.get() @@ -117,15 +129,19 @@ class MessageTool(Tool): if not self._send_callback: return "Error: Message sending not configured" + metadata = { + "message_id": message_id, + } if message_id else {} + if self._record_channel_delivery_var.get(): + metadata["_record_channel_delivery"] = True + msg = OutboundMessage( channel=channel, chat_id=chat_id, content=content, media=media or [], buttons=buttons or [], - metadata={ - "message_id": message_id, - } if message_id else {}, + metadata=metadata, ) try: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 684e7f4c..e403d545 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -716,8 +716,20 @@ def _run_gateway( else f"{channel}:{chat_id}" ) - async def _deliver_to_channel(msg: OutboundMessage, *, record: bool = True) -> None: + async def _deliver_to_channel(msg: OutboundMessage, *, record: bool = False) -> None: """Publish a user-visible message and mirror it into that channel's session.""" + metadata = dict(msg.metadata or {}) + record = record or bool(metadata.pop("_record_channel_delivery", False)) + if metadata != (msg.metadata or {}): + msg = OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=msg.content, + reply_to=msg.reply_to, + media=msg.media, + metadata=metadata, + buttons=msg.buttons, + ) if ( record and msg.channel != "cli" @@ -762,6 +774,10 @@ def _run_gateway( async def _silent(*_args, **_kwargs): pass + message_record_token = None + if isinstance(message_tool, MessageTool): + message_record_token = message_tool.set_record_channel_delivery(True) + try: resp = await agent.process_direct( reminder_note, @@ -773,10 +789,11 @@ def _run_gateway( finally: if isinstance(cron_tool, CronTool) and cron_token is not None: cron_tool.reset_cron_context(cron_token) + if isinstance(message_tool, MessageTool) and message_record_token is not None: + message_tool.reset_record_channel_delivery(message_record_token) response = resp.content if resp else "" - message_tool = agent.tools.get("message") if job.payload.deliver and isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: return response @@ -790,7 +807,8 @@ def _run_gateway( channel=job.payload.channel or "cli", chat_id=job.payload.to, content=response, - ) + ), + record=True, ) return response @@ -853,7 +871,10 @@ def _run_gateway( if channel == "cli": return # No external channel available to deliver to - await _deliver_to_channel(OutboundMessage(channel=channel, chat_id=chat_id, content=response)) + await _deliver_to_channel( + OutboundMessage(channel=channel, chat_id=chat_id, content=response), + record=True, + ) hb_cfg = config.gateway.heartbeat heartbeat = HeartbeatService( diff --git a/tests/tools/test_message_tool.py b/tests/tools/test_message_tool.py index b65b5cd8..18a88121 100644 --- a/tests/tools/test_message_tool.py +++ b/tests/tools/test_message_tool.py @@ -1,6 +1,7 @@ import pytest from nanobot.agent.tools.message import MessageTool +from nanobot.bus.events import OutboundMessage @pytest.mark.asyncio @@ -29,3 +30,23 @@ async def test_message_tool_rejects_malformed_buttons(bad) -> None: content="hi", channel="telegram", chat_id="1", buttons=bad, ) assert result == "Error: buttons must be a list of list of strings" + + +@pytest.mark.asyncio +async def test_message_tool_marks_channel_delivery_only_when_enabled() -> None: + sent: list[OutboundMessage] = [] + + async def _send(msg: OutboundMessage) -> None: + sent.append(msg) + + tool = MessageTool(send_callback=_send) + + await tool.execute(content="normal", channel="telegram", chat_id="1") + token = tool.set_record_channel_delivery(True) + try: + await tool.execute(content="cron", channel="telegram", chat_id="1") + finally: + tool.reset_record_channel_delivery(token) + + assert sent[0].metadata == {} + assert sent[1].metadata == {"_record_channel_delivery": True}