From 380309016a5547e85f4fea9c6cdc323ecdd73f35 Mon Sep 17 00:00:00 2001 From: mt-huerta <5499466+mt-huerta@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:55:23 -0400 Subject: [PATCH] fix(agent): complete thread-session routing for spawn dispatch and system-channel branch Builds on PR #3463 (commit 038a140), which introduced metadata and session_key parameters through _LoopHook and _set_tool_context for the cron and message tools. Three downstream gaps remained: 1. _set_tool_context's body still computes effective_key from channel:chat_id and passes that to spawn, even when the caller provides a thread-scoped session_key. The new parameter is wired in for cron/message but spawn dispatch ignores it. Result: subagent announces from threaded callers carry a channel-only session_key_override, dropping thread_ts. 2. _process_message's system-channel branch loads the session via key = f"{channel}:{chat_id}", ignoring msg.session_key_override. So even when the announce InboundMessage carries the right override (after fix 1), the consumer side discards it and routes to the channel-level session. 3. The OutboundMessage returned from the system-channel branch has no metadata, so slack's outbound dispatcher has no thread_ts to use and posts the LLM's reply to the channel top-level rather than the originating thread. This change closes all three gaps with three small edits in loop.py. Behavior change: - Slack channels with reply_in_thread: true: subagent announces and follow-up replies now arrive in the originating thread session instead of leaking into the channel-level session. - Other channels constructing thread-scoped session keys (matrix threads, telegram thread mode, etc.): the session-loading and effective-key fixes apply identically since they're platform-agnostic. The outbound thread_ts reconstruction is slack-specific by virtue of the session-key format slack uses; other channels would benefit from the same pattern but are out of scope for this PR. - Unified session mode: no change. Falls back to UNIFIED_SESSION_KEY when session_key is not provided. - CLI / non-channel callers: no change. They don't pass session_key and the fallback to f"{channel}:{chat_id}" matches prior behavior. Reproducer (slack with reply_in_thread: true): 1. From a slack thread, send a message that triggers a subagent spawn. 2. Before fix: announce lands in slack:.jsonl session, parent agent in the thread never sees the completion event, eventual reply (if any) posts to the channel top-level, not the thread. 3. After fix: announce lands in slack::.jsonl, parent agent in the thread responds within seconds, reply posts in the thread. --- nanobot/agent/loop.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index f6bd5e2e7..f2ec1e252 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -403,7 +403,16 @@ class AgentLoop: session_key: str | None = None, ) -> None: """Update context for all tools that need routing info.""" - effective_key = UNIFIED_SESSION_KEY if self._unified_session else f"{channel}:{chat_id}" + # When the caller threads a thread-scoped session_key (e.g. slack with + # reply_in_thread: true), honor it so spawn announces route back to + # the originating thread session. Falls back to unified mode or + # channel:chat_id for callers that don't have a thread-scoped key. + if session_key is not None: + effective_key = session_key + elif self._unified_session: + effective_key = UNIFIED_SESSION_KEY + else: + effective_key = f"{channel}:{chat_id}" for name in ("message", "spawn", "cron", "my"): if tool := self.tools.get(name): if hasattr(tool, "set_context"): @@ -830,7 +839,10 @@ class AgentLoop: msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id) ) logger.info("Processing system message from {}", msg.sender_id) - key = f"{channel}:{chat_id}" + # Honor session_key_override so subagent announces from threaded + # callers route to the originating thread session, not the + # channel-level session derived from chat_id. + key = msg.session_key_override or f"{channel}:{chat_id}" session = self.sessions.get_or_create(key) if self._restore_runtime_checkpoint(session): self.sessions.save(session) @@ -885,11 +897,20 @@ class AgentLoop: options, channel, ) + # Reconstruct channel-specific metadata from session.key so the + # outbound reply lands in the originating thread (not the channel + # top-level). The announce InboundMessage carries only + # injected_event metadata; we recover thread_ts from the session + # key, which slack writes as "slack::". + outbound_metadata: dict[str, Any] = {} + if channel == "slack" and key.startswith("slack:") and key.count(":") >= 2: + outbound_metadata["slack"] = {"thread_ts": key.split(":", 2)[2]} return OutboundMessage( channel=channel, chat_id=chat_id, content=content, buttons=buttons, + metadata=outbound_metadata, ) # Extract document text from media at the processing boundary so all