diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index d973d860c..1db7523c6 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -861,6 +861,14 @@ class AgentLoop: This method detects such cross-channel sends and appends a lightweight assistant entry to the target session so it has the necessary context. + + Improvements over the initial implementation: + - Use ``sessions.get_or_create()`` instead of accessing ``_cache`` + directly, so sessions persisted on disk but evicted from memory are + still found. + - Persist ``media`` file paths alongside ``content`` so the target + session retains full context about attachments. + - Record ``_source_session`` to make the provenance traceable. """ from datetime import datetime @@ -885,19 +893,24 @@ class AgentLoop: continue # same session, nothing to do content = args.get("content", "") - if not content: + media = args.get("media") + if not content and not media: continue - target_session = self.sessions._cache.get(target_key) - if target_session is None: - continue # target session doesn't exist yet, skip silently + # Use the public API so disk-persisted sessions are loaded too. + target_session = self.sessions.get_or_create(target_key) - target_session.messages.append({ + entry: dict[str, Any] = { "role": "assistant", "content": content, "timestamp": datetime.now().isoformat(), "_cross_channel": True, - }) + "_source_session": source_session.key, + } + if media: + entry["_media"] = media + + target_session.messages.append(entry) target_session.updated_at = datetime.now() self.sessions.save(target_session) logger.info( diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 2ed0624a2..2e449abd5 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -57,6 +57,12 @@ class Session: for key in ("tool_calls", "tool_call_id", "name", "reasoning_content"): if key in message: entry[key] = message[key] + # Annotate cross-channel messages so the LLM knows the provenance, + # but keep the entry clean of internal metadata keys. + if message.get("_cross_channel"): + source = message.get("_source_session", "unknown") + prefix = f"[Sent from {source}] " + entry["content"] = prefix + (entry.get("content") or "") out.append(entry) return out diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index ae1402e0e..e29d74a29 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -409,7 +409,9 @@ def test_cross_channel_same_session_not_duplicated(tmp_path: Path) -> None: assert all(not m.get("_cross_channel") for m in session.messages) -def test_cross_channel_target_session_not_exist(tmp_path: Path) -> None: +def test_cross_channel_target_session_not_exist_creates_session(tmp_path: Path) -> None: + """When the target session does not exist yet, get_or_create will create it + and the cross-channel message should still be persisted.""" loop = _make_full_loop(tmp_path) source_session = loop.sessions.get_or_create("websocket:ws-xyz") @@ -418,8 +420,126 @@ def test_cross_channel_target_session_not_exist(tmp_path: Path) -> None: ) loop._save_turn(source_session, msgs, skip=1) - # Target session was never created, so no error should occur - assert "feishu:ou_nonexistent" not in loop.sessions._cache + # Target session is now auto-created with the cross-channel message + target = loop.sessions.get_or_create("feishu:ou_nonexistent") + cross_msgs = [m for m in target.messages if m.get("_cross_channel")] + assert len(cross_msgs) == 1 + assert cross_msgs[0]["content"] == "Report: audit complete" + + +def test_cross_channel_persists_media_attachments(tmp_path: Path) -> None: + """When the message tool call includes media, the cross-channel entry + should preserve the media paths so the target session has full context.""" + loop = _make_full_loop(tmp_path) + source_key = "websocket:ws-media" + target_key = "telegram:tg_user1" + + target_session = loop.sessions.get_or_create(target_key) + target_session.add_message("user", "waiting for report") + loop.sessions.save(target_session) + + source_session = loop.sessions.get_or_create(source_key) + msgs = [ + {"role": "user", "content": "send chart to telegram"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_m1", + "type": "function", + "function": { + "name": "message", + "arguments": json.dumps({ + "content": "Here is the chart", + "channel": "telegram", + "chat_id": "tg_user1", + "media": ["/tmp/chart.png", "/tmp/data.csv"], + }), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_m1", + "name": "message", + "content": "Message sent to telegram:tg_user1", + }, + ] + loop._save_turn(source_session, msgs, skip=1) + + loop.sessions.invalidate(target_key) + target = loop.sessions.get_or_create(target_key) + cross_msgs = [m for m in target.messages if m.get("_cross_channel")] + assert len(cross_msgs) == 1 + assert cross_msgs[0]["content"] == "Here is the chart" + assert cross_msgs[0]["_media"] == ["/tmp/chart.png", "/tmp/data.csv"] + + +def test_cross_channel_records_source_session(tmp_path: Path) -> None: + """Cross-channel entries should include _source_session for traceability.""" + loop = _make_full_loop(tmp_path) + source_key = "cron:heartbeat" + target_key = "feishu:ou_trace" + + target_session = loop.sessions.get_or_create(target_key) + target_session.add_message("user", "hi") + loop.sessions.save(target_session) + + source_session = loop.sessions.get_or_create(source_key) + msgs = _cross_channel_messages( + source_channel="cron", source_chat_id="heartbeat", + target_channel="feishu", target_chat_id="ou_trace", + ) + loop._save_turn(source_session, msgs, skip=1) + + loop.sessions.invalidate(target_key) + target = loop.sessions.get_or_create(target_key) + cross_msgs = [m for m in target.messages if m.get("_cross_channel")] + assert len(cross_msgs) == 1 + assert cross_msgs[0]["_source_session"] == "cron:heartbeat" + + +def test_cross_channel_media_only_no_content(tmp_path: Path) -> None: + """A message with media but empty content should still be persisted.""" + loop = _make_full_loop(tmp_path) + target_key = "discord:ch_img" + + target_session = loop.sessions.get_or_create(target_key) + loop.sessions.save(target_session) + + source_session = loop.sessions.get_or_create("websocket:ws-img") + msgs = [ + {"role": "user", "content": "send image"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_img", + "type": "function", + "function": { + "name": "message", + "arguments": json.dumps({ + "content": "", + "channel": "discord", + "chat_id": "ch_img", + "media": ["/tmp/photo.jpg"], + }), + }, + } + ], + }, + {"role": "tool", "tool_call_id": "call_img", "name": "message", "content": "ok"}, + ] + loop._save_turn(source_session, msgs, skip=1) + + loop.sessions.invalidate(target_key) + target = loop.sessions.get_or_create(target_key) + cross_msgs = [m for m in target.messages if m.get("_cross_channel")] + assert len(cross_msgs) == 1 + assert cross_msgs[0]["_media"] == ["/tmp/photo.jpg"] def test_cross_channel_non_message_tools_ignored(tmp_path: Path) -> None: @@ -453,3 +573,33 @@ def test_cross_channel_non_message_tools_ignored(tmp_path: Path) -> None: target = loop.sessions.get_or_create("feishu:ou_tgt") cross_msgs = [m for m in target.messages if m.get("_cross_channel")] assert len(cross_msgs) == 0 + + +def test_cross_channel_get_history_annotates_provenance(tmp_path: Path) -> None: + """get_history() should prefix cross-channel messages with source info + so the LLM knows where the message came from.""" + loop = _make_full_loop(tmp_path) + target_key = "feishu:ou_hist" + + target_session = loop.sessions.get_or_create(target_key) + target_session.add_message("user", "hello") + # Simulate a cross-channel entry as _persist_cross_channel_calls would create + target_session.messages.append({ + "role": "assistant", + "content": "Daily report ready", + "_cross_channel": True, + "_source_session": "cron:daily-report", + }) + loop.sessions.save(target_session) + + loop.sessions.invalidate(target_key) + target = loop.sessions.get_or_create(target_key) + history = target.get_history() + + # Find the annotated message + annotated = [m for m in history if "cron:daily-report" in m.get("content", "")] + assert len(annotated) == 1 + assert annotated[0]["content"] == "[Sent from cron:daily-report] Daily report ready" + # Internal metadata keys should NOT leak into the history output + assert "_cross_channel" not in annotated[0] + assert "_source_session" not in annotated[0]