fix(discord): full thread support with session isolation and allowlist enforcement

Discord threads use their own channel IDs, so allowChannels was blocking
thread replies unless each thread ID was listed explicitly.

- Include the thread parent channel ID as an allowlist candidate
- Enforce allow_channels on slash commands (previously bypassed)
- Show parent channel ID in runtime context, reply to the thread
- Fix subagent cancel key via effective_key propagation
- Detect bot mentions via raw_mentions and reply-to-bot references
- Cache seen thread channels for outbound delivery
- Ignore system messages that become empty prompts
This commit is contained in:
Bongjin Lee
2026-04-23 04:05:39 +09:00
parent e3bca929fb
commit 93ca791ac6
5 changed files with 522 additions and 22 deletions
+43 -9
View File
@@ -26,8 +26,8 @@ from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.notebook import NotebookEditTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.search import GlobTool, GrepTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.self import MyTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage, OutboundMessage
@@ -62,6 +62,7 @@ class _LoopHook(AgentHook):
channel: str = "cli",
chat_id: str = "direct",
message_id: str | None = None,
effective_key: str | None = None,
) -> None:
super().__init__(reraise=True)
self._loop = agent_loop
@@ -71,6 +72,7 @@ class _LoopHook(AgentHook):
self._channel = channel
self._chat_id = chat_id
self._message_id = message_id
self._effective_key = effective_key
self._stream_buf = ""
def wants_streaming(self) -> bool:
@@ -107,7 +109,12 @@ class _LoopHook(AgentHook):
for tc in context.tool_calls:
args_str = json.dumps(tc.arguments, ensure_ascii=False)
logger.info("Tool call: {}({})", tc.name, args_str[:200])
self._loop._set_tool_context(self._channel, self._chat_id, self._message_id)
self._loop._set_tool_context(
self._channel,
self._chat_id,
self._message_id,
effective_key=self._effective_key,
)
async def after_iteration(self, context: AgentHookContext) -> None:
u = context.usage or {}
@@ -316,16 +323,29 @@ class AgentLoop:
finally:
self._mcp_connecting = False
def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
def _set_tool_context(
self,
channel: str,
chat_id: str,
message_id: str | None = None,
*,
effective_key: str | None = None,
) -> None:
"""Update context for all tools that need routing info."""
# Compute the effective session key (accounts for unified sessions)
# so that subagent results route to the correct pending queue.
effective_key = UNIFIED_SESSION_KEY if self._unified_session else f"{channel}:{chat_id}"
context_key = (
effective_key
if effective_key is not None
else UNIFIED_SESSION_KEY
if self._unified_session
else f"{channel}:{chat_id}"
)
for name in ("message", "spawn", "cron", "my"):
if tool := self.tools.get(name):
if hasattr(tool, "set_context"):
if name == "spawn":
tool.set_context(channel, chat_id, effective_key=effective_key)
tool.set_context(channel, chat_id, effective_key=context_key)
else:
tool.set_context(channel, chat_id, *([message_id] if name == "message" else []))
@@ -338,6 +358,11 @@ class AgentLoop:
return strip_think(text) or None
@staticmethod
def _runtime_chat_id(msg: InboundMessage) -> str:
"""Return the chat id shown in runtime metadata for the model."""
return str(msg.metadata.get("context_chat_id") or msg.chat_id)
@staticmethod
def _tool_hint(tool_calls: list) -> str:
"""Format tool calls as concise hints with smart abbreviation."""
@@ -393,6 +418,7 @@ class AgentLoop:
channel: str = "cli",
chat_id: str = "direct",
message_id: str | None = None,
effective_key: str | None = None,
pending_queue: asyncio.Queue | None = None,
) -> tuple[str | None, list[str], list[dict], str, bool]:
"""Run the agent iteration loop.
@@ -412,6 +438,7 @@ class AgentLoop:
channel=channel,
chat_id=chat_id,
message_id=message_id,
effective_key=effective_key,
)
hook: AgentHook = (
CompositeHook([loop_hook] + self._extra_hooks) if self._extra_hooks else loop_hook
@@ -443,7 +470,7 @@ class AgentLoop:
user_content = self.context._build_user_content(content, media)
runtime_ctx = self.context._build_runtime_context(
pending_msg.channel,
pending_msg.chat_id,
self._runtime_chat_id(pending_msg),
self.context.timezone,
)
if isinstance(user_content, str):
@@ -759,7 +786,7 @@ class AgentLoop:
is_subagent = msg.sender_id == "subagent"
if is_subagent and self._persist_subagent_followup(session, msg):
self.sessions.save(session)
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"), effective_key=key)
history = session.get_history(max_messages=0)
current_role = "assistant" if is_subagent else "user"
@@ -776,6 +803,7 @@ class AgentLoop:
final_content, _, all_msgs, _, _ = await self._run_agent_loop(
messages, session=session, channel=channel, chat_id=chat_id,
message_id=msg.metadata.get("message_id"),
effective_key=key,
pending_queue=pending_queue,
)
self._save_turn(session, all_msgs, 1 + len(history))
@@ -817,7 +845,12 @@ class AgentLoop:
session_summary=pending,
)
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
self._set_tool_context(
msg.channel,
msg.chat_id,
msg.metadata.get("message_id"),
effective_key=key,
)
if message_tool := self.tools.get("message"):
if isinstance(message_tool, MessageTool):
message_tool.start_turn()
@@ -830,7 +863,7 @@ class AgentLoop:
session_summary=pending,
media=msg.media if msg.media else None,
channel=msg.channel,
chat_id=msg.chat_id,
chat_id=self._runtime_chat_id(msg),
)
async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
@@ -883,6 +916,7 @@ class AgentLoop:
channel=msg.channel,
chat_id=msg.chat_id,
message_id=msg.metadata.get("message_id"),
effective_key=key,
pending_queue=pending_queue,
)