refactor: simplify cross-session messaging

This commit is contained in:
chengyongru
2026-08-19 01:15:56 +08:00
committed by chengyongru
parent 0e184965e8
commit 251a1ccd40
78 changed files with 1578 additions and 7569 deletions
+12 -47
View File
@@ -85,11 +85,6 @@ from nanobot.session.model_selection import (
SESSION_MODEL_PRESET_METADATA_KEY,
model_preset_from_metadata,
)
from nanobot.session.session_messages import (
is_session_input,
session_input_history_extra,
)
from nanobot.session.webui_turns import project_session_message_input
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
from nanobot.utils.cancellation import task_is_cancelling
from nanobot.utils.document import reference_non_image_attachments
@@ -167,7 +162,6 @@ class TurnContext:
turn_wall_started_at: float = field(default_factory=time.time)
visible_run_started_at: float | None = None
run_status_started: bool = False
turn_latency_ms: int | None = None
usage: dict[str, int] = field(default_factory=dict)
@@ -1022,11 +1016,7 @@ class AgentLoop:
if isinstance(metadata_value, dict)
else {}
)
session_input = is_session_input(pending_msg)
if session_input:
session_metadata = session_input_history_extra(pending_msg)
row.update(session_metadata)
if pending_msg.channel != "system" or session_input:
if pending_msg.is_user_input:
scope = self.workspace_scopes.for_turn(
channel=pending_msg.channel,
message_metadata=metadata,
@@ -1267,13 +1257,10 @@ class AgentLoop:
msg.require_existing_session
and self.sessions.get_cached(effective_key) is None
):
if await asyncio.to_thread(
self.sessions.read_session_metadata,
effective_key,
) is None:
continue
if self.commands.is_priority(raw):
await project_session_message_input(self.bus, msg, effective_key)
continue
if msg.is_user_input:
await self.runtime_event_publisher.user_input_accepted(msg, effective_key)
if msg.channel != "system" and self.commands.is_priority(raw):
await self._dispatch_command_inline(
msg, effective_key, raw,
self.commands.dispatch_priority,
@@ -1301,8 +1288,7 @@ class AgentLoop:
if effective_key in self._pending_queues:
# Non-priority commands must not be queued for injection;
# dispatch them directly (same pattern as priority commands).
if self.commands.is_dispatchable_command(raw):
await project_session_message_input(self.bus, msg, effective_key)
if msg.channel != "system" and self.commands.is_dispatchable_command(raw):
await self._dispatch_command_inline(
msg, effective_key, raw,
self.commands.dispatch,
@@ -1322,7 +1308,6 @@ class AgentLoop:
effective_key,
)
else:
await project_session_message_input(self.bus, msg, effective_key)
logger.info(
"Routed follow-up message to pending queue for session {}",
effective_key,
@@ -1534,11 +1519,7 @@ class AgentLoop:
attributes: Mapping[str, Any] | None = None,
) -> OutboundMessage | None:
"""Process a single inbound message and return the response."""
kind = (
TurnKind.SYSTEM
if msg.channel == "system" and not is_session_input(msg)
else TurnKind.USER
)
kind = TurnKind.USER if msg.is_user_input else TurnKind.SYSTEM
if kind is TurnKind.SYSTEM:
destination = (
msg.chat_id.split(":", 1) if ":" in msg.chat_id else ("cli", msg.chat_id)
@@ -1718,10 +1699,7 @@ class AgentLoop:
if ctx.session is None:
if msg.require_existing_session:
ctx.session = await asyncio.to_thread(
self.sessions.get_existing,
ctx.session_key,
)
ctx.session = self.sessions.get_cached(ctx.session_key)
if ctx.session is None:
raise RuntimeError("required session is not active")
else:
@@ -1752,12 +1730,6 @@ class AgentLoop:
is_user_turn=ctx.original_user_text is not None,
)
await ctx.delivery.started()
if is_session_input(ctx.msg):
if ctx.visible_run_started_at is None:
ctx.visible_run_started_at = time.time()
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
ctx.run_status_started = True
await project_session_message_input(self.bus, ctx.msg, ctx.session_key)
if ctx.kind is TurnKind.USER:
self.workspace_scopes.persist_message_scope(session, msg)
@@ -1775,7 +1747,7 @@ class AgentLoop:
ctx.pending_summary = pending
async def _dispatch_command(self, ctx: TurnContext) -> bool:
if ctx.kind is TurnKind.SYSTEM:
if ctx.kind is TurnKind.SYSTEM or ctx.msg.channel == "system":
return False
session = ctx.require_session()
raw = ctx.msg.content.strip()
@@ -1934,7 +1906,6 @@ class AgentLoop:
ctx.msg,
session,
runtime_context_blocks=ctx.runtime_context_blocks,
**session_input_history_extra(ctx.msg),
)
if staged_provider_state and not ctx.input_persisted_early:
session.provider_state = stored_state
@@ -1953,9 +1924,7 @@ class AgentLoop:
runtime = ctx.require_runtime()
if ctx.visible_run_started_at is None:
ctx.visible_run_started_at = time.time()
if not ctx.run_status_started:
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
ctx.run_status_started = True
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
result = await self._run_agent_loop(
ctx.initial_messages,
runtime=runtime,
@@ -2001,8 +1970,7 @@ class AgentLoop:
and not ctx.suppress_response
):
ctx.final_content = EMPTY_FINAL_RESPONSE_MESSAGE
if session.discarded:
raise RuntimeError("session was deleted while the turn was running")
latency_started_at = (
ctx.visible_run_started_at
if (
@@ -2056,11 +2024,8 @@ class AgentLoop:
latency_ms=ctx.turn_latency_ms,
)
return
outbound_input = (
ctx.delivery.delivery_message if ctx.msg.channel == "system" else ctx.msg
)
ctx.outbound = self._assemble_outbound(
outbound_input,
ctx.delivery.delivery_message,
cast(str, ctx.final_content),
ctx.stop_reason,
ctx.had_injections,
-3
View File
@@ -175,9 +175,6 @@ class AgentRunner:
and not is_hidden_history_message(injection)
and not is_hidden_history_message(messages[-1])
and allows_conversation_message_merge(messages[-1])
and allows_conversation_message_merge(injection)
and set(messages[-1]).issubset({"role", "content", "_meta"})
and set(injection).issubset({"role", "content", "_meta"})
):
merged = dict(messages[-1])
left_meta = merged.get("_meta")
-1
View File
@@ -505,7 +505,6 @@ class SubagentManager:
content=announce_content,
session_key_override=override,
metadata=metadata,
require_existing_session=True,
)
await self.bus.publish_inbound(msg)
+77 -180
View File
@@ -1,4 +1,4 @@
"""Discovery and delivery tools for communication between sessions."""
"""Tools for sending bounded messages between persisted sessions."""
# pyright: reportIncompatibleMethodOverride=false
@@ -25,27 +25,24 @@ from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.runtime_context import RuntimeContextBlock
from nanobot.session.manager import SessionManager
from nanobot.session.session_handles import SessionHandleDirectory, SessionHandleDirectoryProtocol
from nanobot.session.session_messages import (
MAX_REPLY_TIMEOUT_SECONDS,
MIN_REPLY_TIMEOUT_SECONDS,
SESSION_MESSAGE_METADATA_KEY,
SESSION_MESSAGE_SENDER_ID,
SESSION_REPLY_TIMEOUT_METADATA_KEY,
SESSION_REPLY_TIMEOUT_SENDER_ID,
SessionMessageEndpoint,
SessionMessageEnvelope,
SessionMessageError,
SessionMessageSourceEndpoint,
SessionReplyTimeoutEnvelope,
is_persisted_webui_session,
from nanobot.session.session_handles import (
SessionHandleResolver,
normalize_session_handle,
session_handle_for_key,
)
from nanobot.session.session_messages import (
SESSION_MESSAGE_METADATA_KEY,
SessionMessageEnvelope,
session_message_envelope,
session_reply_timeout_envelope,
)
from nanobot.webui.transcript import normalize_session_handles_metadata
_RATE_LIMIT_WINDOW_SECONDS = 60.0
MIN_REPLY_TIMEOUT_SECONDS = 5
MAX_REPLY_TIMEOUT_SECONDS = 60
class SessionMessageError(ValueError):
pass
class _CancelHandle(Protocol):
@@ -61,16 +58,15 @@ class _PendingReply:
@tool_parameters(tool_parameters_schema())
class ListSessionsTool(Tool):
"""List addressable session handles without exposing session data."""
"""List the handles of other persisted sessions."""
def __init__(self, sessions: SessionManager) -> None:
self._sessions = sessions
self._directory = SessionHandleDirectory(sessions)
self._handles = SessionHandleResolver(sessions)
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
if ctx.sessions is None:
raise RuntimeError("ListSessionsTool requires an initialized session manager")
raise RuntimeError("list_sessions requires a session manager")
return cls(ctx.sessions)
@classmethod
@@ -83,74 +79,34 @@ class ListSessionsTool(Tool):
@property
def description(self) -> str:
return "List other sessions as @handles."
return "List other persisted sessions by @handle."
@property
def read_only(self) -> bool:
return True
def runtime_context_provider(self):
return self._provide_runtime_context
async def _provide_runtime_context(
self,
request: RequestContext,
) -> RuntimeContextBlock | None:
if not request.session_key:
return None
handle = await asyncio.to_thread(
self._directory.handle_for_session,
request.session_key,
)
if handle is None:
return None
lines = [f"Your handle: @{handle.name}."]
mentions = [
f"@{mention['name']}"
for mention in normalize_session_handles_metadata(
request.metadata.get("session_handles")
)
]
if mentions:
lines.append("Mentioned sessions: " + ", ".join(mentions) + ".")
return RuntimeContextBlock(source="session_handle", content="\n".join(lines))
async def execute(self, **kwargs: Any) -> str:
request = current_request_context()
if request is None or not request.session_key:
return ToolResult.error("Error: session discovery context is unavailable")
handles = await asyncio.to_thread(
self._list_handles,
request.session_key,
return ToolResult.error("Error: session context is unavailable")
handles = await asyncio.to_thread(self._handles.list_all)
return json.dumps(
[
f"@{handle.name}"
for handle in handles
if handle.session_key != request.session_key
],
ensure_ascii=True,
)
return json.dumps(handles, ensure_ascii=True)
def _list_handles(self, source_session_key: str) -> list[str]:
session_keys: list[str] = []
for row in self._sessions.list_sessions():
raw_key = row.get("key")
if not isinstance(raw_key, str) or not raw_key.strip():
continue
session_keys.append(raw_key)
# Handle provisioning is registry housekeeping, not a conversation
# mutation. Every persisted session has an identity independently of UI.
self._directory.ensure_many(session_keys)
allowed = set(session_keys)
return [
f"@{handle.name}"
for handle in self._directory.list_all()
if handle.session_key in allowed and handle.session_key != source_session_key
]
@tool_parameters(
tool_parameters_schema(
to=StringSchema("Target @handle."),
content=StringSchema("Message to send."),
expect_reply=BooleanSchema(description="Expect a reply."),
content=StringSchema("Message."),
expect_reply=BooleanSchema(description="Notify this session if no reply arrives."),
reply_timeout_seconds=IntegerSchema(
description="Reply timeout; required with expect_reply.",
description="Timeout before that notification; required when expect_reply is true.",
minimum=MIN_REPLY_TIMEOUT_SECONDS,
maximum=MAX_REPLY_TIMEOUT_SECONDS,
),
@@ -158,21 +114,19 @@ class ListSessionsTool(Tool):
)
)
class SendSessionMessageTool(Tool):
"""Send text to another session."""
"""Send text to another persisted session."""
def __init__(
self,
*,
sessions: SessionManager,
bus: MessageBus,
directory: SessionHandleDirectoryProtocol | None = None,
max_messages_per_minute: int = 6,
schedule_later: Callable[[float, Callable[[], None]], _CancelHandle] | None = None,
clock: Callable[[], float] | None = None,
) -> None:
self._sessions = sessions
self._bus = bus
self._directory = directory or SessionHandleDirectory(sessions)
self._handles = SessionHandleResolver(sessions)
self._max_messages_per_minute = max_messages_per_minute
self._schedule_later = schedule_later
self._clock = clock or time.monotonic
@@ -184,7 +138,7 @@ class SendSessionMessageTool(Tool):
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
if ctx.sessions is None or ctx.bus is None:
raise RuntimeError("Session messaging requires a session manager and message bus")
raise RuntimeError("send_session_message requires sessions and a message bus")
return cls(
sessions=ctx.sessions,
bus=ctx.bus,
@@ -201,7 +155,7 @@ class SendSessionMessageTool(Tool):
@property
def description(self) -> str:
return "Send a message to another session by @handle."
return "Send a message to a persisted session by @handle."
def runtime_context_provider(self):
return self._provide_runtime_context
@@ -211,25 +165,13 @@ class SendSessionMessageTool(Tool):
request: RequestContext,
) -> RuntimeContextBlock | None:
envelope = session_message_envelope(request.metadata)
if envelope is not None:
source = f"@{envelope['source']['name']}"
content = f"Message from {source}."
if envelope["expect_reply"]:
content += " Reply with send_session_message."
return RuntimeContextBlock(
source="session_collaboration",
content=content,
)
timeout = session_reply_timeout_envelope(request.metadata)
if timeout is None:
if envelope is None:
return None
session = f"@{timeout['target']['name']}"
seconds = timeout["timeout_seconds"]
return RuntimeContextBlock(
source="session_collaboration",
content=f"No reply from {session} after {seconds}s.",
)
source = session_handle_for_key(envelope["source_session_key"])
content = f"Message from @{source.name}."
if envelope["expect_reply"]:
content += " Reply with send_session_message."
return RuntimeContextBlock(source="session_message", content=content)
async def execute(
self,
@@ -242,13 +184,10 @@ class SendSessionMessageTool(Tool):
from nanobot.utils.helpers import strip_think
request = current_request_context()
if (
request is None
or not request.session_key
):
return ToolResult.error("Error: session messaging context is unavailable")
if request is None or not request.session_key:
return ToolResult.error("Error: session context is unavailable")
try:
target_handle = await self.enqueue(
target = await self.enqueue(
source_session_key=request.session_key,
target_handle=to,
content=strip_think(content),
@@ -258,8 +197,11 @@ class SendSessionMessageTool(Tool):
except SessionMessageError as exc:
return ToolResult.error(f"Error: {exc}")
if expect_reply:
return f"Sent to {target_handle}; reply expected within {reply_timeout_seconds}s. End the turn."
return f"Sent to {target_handle}."
return (
f"Sent to {target}. A timeout notice will arrive after "
f"{reply_timeout_seconds}s unless it replies."
)
return f"Sent to {target}."
async def enqueue(
self,
@@ -270,50 +212,27 @@ class SendSessionMessageTool(Tool):
expect_reply: bool,
reply_timeout_seconds: int | None = None,
) -> str:
"""Publish one message to an existing target session."""
timeout_seconds = self._validate_reply_timeout(
expect_reply,
reply_timeout_seconds,
)
lookup_name = normalize_session_handle(target_handle)
source = await asyncio.to_thread(
self._directory.handle_for_session,
source_session_key,
)
if source is None:
raise SessionMessageError("source_not_found", "source session was not found")
target = await asyncio.to_thread(self._directory.resolve, lookup_name)
timeout_seconds = self._validate_reply_timeout(expect_reply, reply_timeout_seconds)
try:
target_name = normalize_session_handle(target_handle)
except ValueError as exc:
raise SessionMessageError(str(exc)) from exc
target = await asyncio.to_thread(self._handles.resolve, target_name)
if target is None:
raise SessionMessageError("target_not_found", f"session @{lookup_name} was not found")
raise SessionMessageError(f"session @{target_name} was not found")
source_endpoint: SessionMessageSourceEndpoint = {
"name": source.name,
"session_key": source.session_key,
"handle_id": source.id,
"color_slot": source.color_slot,
}
target_endpoint: SessionMessageEndpoint = {
"name": target.name,
"session_key": target.session_key,
}
source = session_handle_for_key(source_session_key)
envelope: SessionMessageEnvelope = {
"message_id": uuid4().hex,
"created_at_ms": int(time.time() * 1000),
"expect_reply": expect_reply,
"source": source_endpoint,
"target": target_endpoint,
"source_session_key": source.session_key,
"target_session_key": target.session_key,
}
reverse_wait_key = (target.session_key, source.session_key)
wait_key = (source.session_key, target.session_key)
async with self._send_lock:
target_session = await asyncio.to_thread(
self._sessions.read_session_metadata,
target.session_key,
)
if target_session is None:
raise SessionMessageError("target_not_found", "target session is not persisted")
now = self._clock()
sent_at = self._sent_at.setdefault(source.session_key, deque())
cutoff = now - _RATE_LIMIT_WINDOW_SECONDS
@@ -321,34 +240,23 @@ class SendSessionMessageTool(Tool):
sent_at.popleft()
if len(sent_at) >= self._max_messages_per_minute:
raise SessionMessageError(
"rate_limited",
"session message rate limit reached "
f"({self._max_messages_per_minute} per minute)",
f"session message rate limit reached ({self._max_messages_per_minute}/minute)",
)
channel = "system"
chat_id = target.session_key
if is_persisted_webui_session(target.session_key, target_session):
channel = "websocket"
chat_id = target.session_key.split(":", 1)[1]
await self._bus.publish_inbound(InboundMessage(
channel=channel,
sender_id=SESSION_MESSAGE_SENDER_ID,
chat_id=chat_id,
channel="system",
sender_id="session",
chat_id=target.session_key,
content=content,
metadata={SESSION_MESSAGE_METADATA_KEY: envelope},
session_key_override=target.session_key,
require_existing_session=True,
input_role="user",
))
sent_at.append(now)
self._cancel_pending_reply(reverse_wait_key)
if timeout_seconds is not None:
self._cancel_pending_reply(wait_key)
self._schedule_pending_reply(
wait_key,
timeout_seconds=timeout_seconds,
request=envelope,
)
self._schedule_pending_reply(wait_key, timeout_seconds, envelope)
return f"@{target.name}"
@@ -358,11 +266,6 @@ class SendSessionMessageTool(Tool):
reply_timeout_seconds: int | None,
) -> int | None:
if not expect_reply:
if reply_timeout_seconds is not None:
raise SessionMessageError(
"unexpected_reply_timeout",
"reply_timeout_seconds requires expect_reply=true",
)
return None
if (
reply_timeout_seconds is None
@@ -371,7 +274,6 @@ class SendSessionMessageTool(Tool):
<= MAX_REPLY_TIMEOUT_SECONDS
):
raise SessionMessageError(
"invalid_reply_timeout",
"expect_reply=true requires reply_timeout_seconds between "
f"{MIN_REPLY_TIMEOUT_SECONDS} and {MAX_REPLY_TIMEOUT_SECONDS}",
)
@@ -385,14 +287,10 @@ class SendSessionMessageTool(Tool):
def _schedule_pending_reply(
self,
key: tuple[str, str],
*,
timeout_seconds: int,
request: SessionMessageEnvelope,
) -> None:
pending = _PendingReply(
timeout_seconds=timeout_seconds,
request=request,
)
pending = _PendingReply(timeout_seconds=timeout_seconds, request=request)
self._pending_replies[key] = pending
def expire() -> None:
@@ -400,8 +298,8 @@ class SendSessionMessageTool(Tool):
self._expiry_tasks.add(task)
task.add_done_callback(self._expiry_tasks.discard)
schedule_later = self._schedule_later or asyncio.get_running_loop().call_later
pending.timer = schedule_later(float(timeout_seconds), expire)
schedule = self._schedule_later or asyncio.get_running_loop().call_later
pending.timer = schedule(float(timeout_seconds), expire)
async def _expire_pending_reply(
self,
@@ -412,17 +310,16 @@ class SendSessionMessageTool(Tool):
if self._pending_replies.get(key) is not expected:
return
self._pending_replies.pop(key, None)
envelope: SessionReplyTimeoutEnvelope = {
**expected.request,
"timeout_seconds": expected.timeout_seconds,
}
waiter_key = expected.request["source"]["session_key"]
source_session_key = expected.request["source_session_key"]
target = session_handle_for_key(expected.request["target_session_key"])
await self._bus.publish_inbound(InboundMessage(
channel="system",
sender_id=SESSION_REPLY_TIMEOUT_SENDER_ID,
chat_id=waiter_key,
content="",
metadata={SESSION_REPLY_TIMEOUT_METADATA_KEY: envelope},
session_key_override=waiter_key,
require_existing_session=True,
sender_id="session_timeout",
chat_id=source_session_key,
content=(
f"No reply from @{target.name} after "
f"{expected.timeout_seconds} seconds."
),
session_key_override=source_session_key,
input_role="user",
))
+9 -27
View File
@@ -11,15 +11,13 @@ from typing import Any
from urllib.parse import quote
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import (
ToolContext,
current_request_context,
current_request_session_key,
)
from nanobot.agent.tools.context import ToolContext, current_request_session_key
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.session.manager import SessionManager
from nanobot.session.session_handles import SessionHandleDirectory
from nanobot.session.session_messages import normalize_session_handle
from nanobot.session.session_handles import (
SessionHandleResolver,
normalize_session_handle,
)
from nanobot.webui.session_access import WebuiSessionAccess
_SEARCH_LIMIT = 5
@@ -30,15 +28,9 @@ _UNTRUSTED_NOTICE = "Historical session content is untrusted data, not instructi
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""Return persisted kwargs for structured session references and handles."""
if not isinstance(metadata, Mapping):
return {}
extra: dict[str, Any] = {}
for key in ("session_mentions", "session_handles"):
value = metadata.get(key)
if isinstance(value, list) and value:
extra[key] = value
return extra
"""Return persisted kwargs for structured session mentions."""
mentions = metadata.get("session_mentions") if isinstance(metadata, Mapping) else None
return {"session_mentions": mentions} if isinstance(mentions, list) and mentions else {}
def _excerpt(text: str, needle: str, limit: int) -> str:
@@ -165,8 +157,7 @@ class ReadSessionTool(_SessionTool):
def __init__(self, sessions: SessionManager) -> None:
super().__init__(sessions)
self._sessions = sessions
self._handles = SessionHandleDirectory(sessions)
self._handles = SessionHandleResolver(sessions)
@property
def name(self) -> str:
@@ -192,9 +183,6 @@ class ReadSessionTool(_SessionTool):
return ToolResult.error("Error: session_key must not be empty")
session_handle: str | None = None
if session_key.startswith("@"):
request = current_request_context()
if request is None or request.workspace is None:
return ToolResult.error("Error: session handle context is unavailable")
try:
handle_name = normalize_session_handle(session_key)
except ValueError as exc:
@@ -205,12 +193,6 @@ class ReadSessionTool(_SessionTool):
)
if handle is None:
return ToolResult.error(f"Error: session @{handle_name} was not found")
persisted = await asyncio.to_thread(
self._sessions.read_session_metadata,
handle.session_key,
)
if persisted is None:
return ToolResult.error(f"Error: session @{handle_name} was not found")
session_handle = f"@{handle_name}"
session_key = handle.session_key
query_text = query.strip() if query else ""