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 ""
+9 -1
View File
@@ -2,7 +2,7 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from nanobot.bus.outbound_events import OutboundEvent
@@ -34,12 +34,20 @@ class InboundMessage:
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
session_key_override: str | None = None # Optional override for thread-scoped sessions
require_existing_session: bool = False
input_role: Literal["user", "system"] | None = None
@property
def session_key(self) -> str:
"""Unique key for session identification."""
return self.session_key_override or f"{self.channel}:{self.chat_id}"
@property
def is_user_input(self) -> bool:
"""Whether this message should enter the conversation as user input."""
if self.input_role is not None:
return self.input_role == "user"
return self.channel != "system"
@dataclass
class OutboundMessage:
+4 -4
View File
@@ -79,12 +79,12 @@ class SessionUpdatedEvent(OutboundEvent):
@dataclass(frozen=True)
class SessionMessageInputEvent(OutboundEvent):
"""One session-authored message projected live into its target WebUI thread."""
class UserInputEvent(OutboundEvent):
"""A user-input row projected by an edge adapter."""
content: str
created_at_ms: int
session_message: dict[str, Any]
provenance: dict[str, Any]
@dataclass(frozen=True)
@@ -147,7 +147,7 @@ def replace_outbound_event(
def _event_content(event: OutboundEvent) -> str:
if isinstance(
event,
ProgressEvent | RetryWaitEvent | StreamDeltaEvent | StreamEndEvent | SessionMessageInputEvent,
ProgressEvent | RetryWaitEvent | StreamDeltaEvent | StreamEndEvent | UserInputEvent,
):
return event.content
return ""
+29 -4
View File
@@ -38,7 +38,14 @@ class SessionTurnStarted:
"""A user/system turn has loaded its session and is about to build context."""
context: RuntimeEventContext
content: str = ""
@dataclass(frozen=True)
class UserInputAccepted:
"""User input was accepted for dispatch or injection into a session."""
context: RuntimeEventContext
content: str
@dataclass(frozen=True)
@@ -94,7 +101,8 @@ class RuntimeModelChanged:
RuntimeEvent = (
SessionTurnStarted
UserInputAccepted
| SessionTurnStarted
| TurnRuntimeAdmitted
| SessionTurnPersisted
| TurnRunStatusChanged
@@ -103,7 +111,8 @@ RuntimeEvent = (
| RuntimeModelChanged
)
RuntimeEventType = (
type[SessionTurnStarted]
type[UserInputAccepted]
| type[SessionTurnStarted]
| type[TurnRuntimeAdmitted]
| type[SessionTurnPersisted]
| type[TurnRunStatusChanged]
@@ -209,6 +218,23 @@ class RuntimeEventPublisher:
self._turn_runtime.pop(session_key, None)
self._turn_usage.pop(session_key, None)
async def user_input_accepted(
self,
msg: InboundMessage,
session_key: str,
) -> None:
await self.bus.publish(
UserInputAccepted(
context=self._context(
channel=msg.channel,
chat_id=msg.chat_id,
session_key=session_key,
metadata=msg.metadata,
),
content=msg.content,
)
)
async def session_turn_started(
self,
msg: InboundMessage,
@@ -222,7 +248,6 @@ class RuntimeEventPublisher:
session_key=session_key,
metadata=msg.metadata,
),
content=msg.content,
)
)
+17 -34
View File
@@ -33,10 +33,10 @@ from nanobot.bus.outbound_events import (
GoalStatusEvent,
ProgressEvent,
RuntimeModelUpdatedEvent,
SessionMessageInputEvent,
SessionUpdatedEvent,
TurnEndEvent,
TurnModelUpdatedEvent,
UserInputEvent,
outbound_event_from_message,
)
from nanobot.bus.queue import MessageBus
@@ -87,7 +87,6 @@ from nanobot.webui.metadata import (
WEBUI_TURN_METADATA_KEY,
)
from nanobot.webui.session_access import (
SessionHandleMention,
SessionMention,
WebuiSessionAccess,
session_mentions_runtime_context,
@@ -1197,11 +1196,9 @@ class WebSocketChannel(BaseChannel):
if mcp_presets:
metadata["mcp_presets"] = mcp_presets
session_mentions: list[SessionMention] = []
session_handles: list[SessionHandleMention] = []
if (
trusted_webui
and self._session_access is not None
and temporary_policy is None
):
session_mentions = await asyncio.to_thread(
self._session_access.normalize_mentions,
@@ -1210,15 +1207,6 @@ class WebSocketChannel(BaseChannel):
)
if session_mentions:
metadata["session_mentions"] = session_mentions
raw_session_handles = envelope.get("session_handles")
if raw_session_handles is not None:
session_handles = await asyncio.to_thread(
self._session_access.normalize_session_handles,
raw_session_handles,
source_session_key=f"{self.name}:{cid}",
)
if session_handles:
metadata["session_handles"] = session_handles
metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
self._workspaces.persist_scope(cid, scope)
is_webui = metadata.get("webui") is True
@@ -1244,7 +1232,6 @@ class WebSocketChannel(BaseChannel):
cli_apps=cli_apps or None,
mcp_presets=mcp_presets or None,
session_mentions=session_mentions or None,
session_handles=session_handles or None,
)
if trusted_webui:
context_blocks: list[RuntimeContextBlock] = []
@@ -1253,9 +1240,9 @@ class WebSocketChannel(BaseChannel):
})
if quote is not None:
context_blocks.append(quote)
reference_context = session_mentions_runtime_context(session_mentions)
if reference_context is not None:
context_blocks.append(reference_context)
session_context = session_mentions_runtime_context(session_mentions)
if session_context is not None:
context_blocks.append(session_context)
if context_blocks:
metadata[RUNTIME_CONTEXT_INPUT_META] = context_blocks
await self._handle_message(
@@ -1273,7 +1260,7 @@ class WebSocketChannel(BaseChannel):
require_existing_session=(
temporary_policy.require_existing_session
if temporary_policy is not None
else is_webui
else False
),
)
accepted = True
@@ -1682,7 +1669,7 @@ class WebSocketChannel(BaseChannel):
if isinstance(
event,
ProgressEvent
| SessionMessageInputEvent
| UserInputEvent
| TurnEndEvent
| SessionUpdatedEvent
| GoalStatusEvent
@@ -1700,14 +1687,13 @@ class WebSocketChannel(BaseChannel):
context_window_tokens=event.context_window_tokens,
)
return
if isinstance(event, SessionMessageInputEvent):
if isinstance(event, UserInputEvent):
if conns:
await self.send_session_message_input(
await self.send_user_input(
msg.chat_id,
content=event.content,
created_at_ms=event.created_at_ms,
session_message=event.session_message,
metadata=msg.metadata,
provenance=event.provenance,
)
return
if isinstance(event, GoalStateSyncEvent):
@@ -2064,33 +2050,30 @@ class WebSocketChannel(BaseChannel):
for connection in conns:
await self._safe_send_to(connection, raw, label=" session_updated ")
async def send_session_message_input(
async def send_user_input(
self,
chat_id: str,
*,
content: str,
created_at_ms: int,
session_message: dict[str, Any],
metadata: dict[str, Any] | None = None,
provenance: dict[str, Any],
) -> None:
"""Project a session message before the target model starts responding."""
"""Project user input produced outside a WebSocket connection."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {
"event": "session_message",
"event": "user_message",
"chat_id": chat_id,
"text": content,
"created_at_ms": created_at_ms,
"session_message": session_message,
"turn_phase": "user",
"starts_turn": False,
}
turn_id = (metadata or {}).get(WEBUI_TURN_METADATA_KEY)
if isinstance(turn_id, str) and turn_id:
body["turn_id"] = turn_id
if provenance:
body["provenance"] = provenance
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" session_message ")
await self._safe_send_to(connection, raw, label=" user_message ")
async def send_runtime_model_updated(
self,
@@ -28,10 +28,10 @@ from nanobot.bus.outbound_events import (
GoalStatusEvent,
ProgressEvent,
RuntimeModelUpdatedEvent,
SessionMessageInputEvent,
SessionUpdatedEvent,
TurnEndEvent,
TurnModelUpdatedEvent,
UserInputEvent,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.websocket.runtime import (
@@ -48,6 +48,7 @@ from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.session import webui_turns as wth
from nanobot.session.manager import SessionManager
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
from nanobot.session.session_handles import session_handle_for_key
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
from nanobot.webui.http_utils import (
http_error as _http_error,
@@ -540,7 +541,7 @@ async def test_temporary_looking_id_does_not_define_session_policy(bus, tmp_path
)
inbound = bus.publish_inbound.await_args.args[0]
assert inbound.require_existing_session is True
assert inbound.require_existing_session is False
assert inbound.session_key_override is None
session = sessions.get_cached("websocket:temporary-looking-but-persistent")
assert session is not None
@@ -2007,6 +2008,41 @@ async def test_send_broadcasts_runtime_model_updates() -> None:
assert payload["model_preset"] == "fast"
@pytest.mark.asyncio
async def test_send_projects_external_user_input_to_existing_wire_event() -> None:
bus = MessageBus()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(
OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
event=UserInputEvent(
content="hello from another session",
created_at_ms=1234,
provenance={"name": "mira-deadbeef00"},
),
)
)
payload = json.loads(mock_ws.send.call_args.args[0])
assert payload == {
"event": "user_message",
"chat_id": "chat-1",
"text": "hello from another session",
"created_at_ms": 1234,
"starts_turn": False,
"provenance": {"name": "mira-deadbeef00"},
}
@pytest.mark.asyncio
async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
bus = MessageBus()
@@ -2066,57 +2102,6 @@ def test_attach_fields_restore_the_session_model_and_latest_usage() -> None:
}
@pytest.mark.asyncio
async def test_send_projects_session_message_only_to_the_target_chat() -> None:
bus = MessageBus()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
target = AsyncMock()
other = AsyncMock()
channel._attach(target, "target")
channel._attach(other, "other")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="target",
content="Review this now.",
metadata={WEBUI_TURN_METADATA_KEY: "session-message-turn-1"},
event=SessionMessageInputEvent(
content="Review this now.",
created_at_ms=1234,
session_message={
"direction": "incoming",
"message_id": "session-message-1",
"session": {
"id": "handle_11111111111111111111111111111111",
"name": "kai",
"session_key": "websocket:source",
"color_slot": 2,
},
},
),
))
assert json.loads(target.send.await_args.args[0]) == {
"event": "session_message",
"chat_id": "target",
"text": "Review this now.",
"created_at_ms": 1234,
"session_message": {
"direction": "incoming",
"message_id": "session-message-1",
"session": {
"id": "handle_11111111111111111111111111111111",
"name": "kai",
"session_key": "websocket:source",
"color_slot": 2,
},
},
"turn_phase": "user",
"turn_id": "session-message-turn-1",
}
other.send.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
bus = MagicMock()
@@ -4971,7 +4956,7 @@ def test_parse_envelope_rejects_legacy_and_garbage() -> None:
assert _parse_envelope('{"type":123}') is None
def test_sessions_list_includes_active_run_started_at(monkeypatch, tmp_path: Path) -> None:
def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None:
from websockets.datastructures import Headers
from websockets.http11 import Request
@@ -4979,7 +4964,7 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch, tmp_path: Pat
from nanobot.webui import ws_http as ws_http_module
bus = MagicMock()
session_manager = SessionManager(tmp_path / "sessions")
session_manager = MagicMock()
sessions = [
{
"key": "websocket:chat-1",
@@ -4988,7 +4973,6 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch, tmp_path: Pat
"title": "Running",
"preview": "work",
"model_preset": "fast",
"_persisted_webui": True,
"path": "/private/path",
},
{
@@ -5016,13 +5000,8 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch, tmp_path: Pat
assert resp.status_code == 200
body = json.loads(resp.body.decode())
workspace_scope = body["sessions"][0].pop("workspace_scope")
handle = body["sessions"][0].pop("handle")
assert workspace_scope["project_path"] == str(channel.gateway.media.workspace_path)
assert workspace_scope["access_mode"] in {"restricted", "full"}
assert handle["id"].startswith("handle_")
assert handle["name"].isascii()
assert handle["name"].islower()
assert 0 <= handle["color_slot"] < 8
assert body["sessions"] == [
{
"key": "websocket:chat-1",
@@ -5032,6 +5011,7 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch, tmp_path: Pat
"preview": "work",
"model_preset": "fast",
"run_started_at": 1_700_000_000.0,
"handle": session_handle_for_key("websocket:chat-1").public_payload(),
}
]
@@ -19,12 +19,11 @@ from nanobot.channels.websocket.runtime import (
WebSocketChannel,
WebSocketConfig,
)
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META
from nanobot.session import webui_turns as wth
from nanobot.session.manager import SessionManager
from nanobot.session.session_handles import SessionHandleDirectory, SessionHandleSnapshot
from nanobot.session.session_handles import session_handle_for_key
from nanobot.webui.gateway_services import build_gateway_services
from nanobot.webui.transcript import append_transcript_object, read_transcript_lines
def _tiny_png_data_url() -> str:
@@ -234,176 +233,39 @@ async def test_message_forwards_normalized_cli_app_attachments() -> None:
@pytest.mark.asyncio
async def test_webui_message_preserves_verified_session_handles_in_focused_chat(tmp_path) -> None:
async def test_webui_message_forwards_verified_session_mentions(tmp_path) -> None:
manager = SessionManager(tmp_path)
current = manager.get_or_create("websocket:current")
current.metadata.update({
"title": "Current",
"webui": True,
WORKSPACE_SCOPE_METADATA_KEY: {
"project_path": str(Path.cwd().resolve()),
"access_mode": "full",
},
})
manager.save(current)
target = manager.get_or_create("websocket:pricing")
target.metadata.update({
"title": "Pricing",
"title_user_edited": True,
"webui": True,
WORKSPACE_SCOPE_METADATA_KEY: {
"project_path": str(Path.cwd().resolve()),
"access_mode": "full",
},
})
target.metadata.update({"title": "Pricing", "title_user_edited": True})
target.add_message("user", "Discuss cloud storage")
manager.save(target)
directory = SessionHandleDirectory(manager)
handles = directory.ensure_many(["websocket:current", "websocket:pricing"])
target_identity = handles["websocket:pricing"]
channel = _make_channel(manager)
mock_conn = AsyncMock()
channel._webui_connections.add(mock_conn)
envelope = {
"type": "message",
"chat_id": "current",
"content": f"@{target_identity.name} review the launch plan",
"content": "Use @pricing",
"webui": True,
"session_handles": [{
"id": target_identity.id,
"name": target_identity.name,
"session_mentions": [{
"name": "pricing",
"session_key": "websocket:pricing",
"title": "Untrusted title",
"color_slot": (target_identity.color_slot + 1) % 8,
}],
}
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_awaited_once()
assert channel._handle_message.call_args.kwargs["chat_id"] == "current"
assert channel._handle_message.call_args.kwargs["content"] == (
f"@{target_identity.name} review the launch plan"
)
metadata = channel._handle_message.call_args.kwargs["metadata"]
assert metadata["session_handles"] == [{
"id": target_identity.id,
"name": target_identity.name,
assert metadata["session_mentions"] == [{
**session_handle_for_key("websocket:pricing").public_payload(),
"session_key": "websocket:pricing",
"color_slot": target_identity.color_slot,
"title": "Pricing",
}]
@pytest.mark.asyncio
async def test_new_webui_chat_can_structurally_mention_its_own_identity(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
manager = SessionManager(tmp_path)
channel = _make_channel(manager)
mock_conn = AsyncMock()
channel._webui_connections.add(mock_conn)
await channel._dispatch_envelope(
mock_conn,
"client-1",
{"type": "new_chat"},
)
events = [json.loads(call.args[0]) for call in mock_conn.send.await_args_list]
chat_id = next(event["chat_id"] for event in events if event["event"] == "attached")
target_key = f"websocket:{chat_id}"
target_identity = SessionHandleDirectory(manager).ensure_many([target_key])[target_key]
mock_conn.send.reset_mock()
await channel._dispatch_envelope(
mock_conn,
"client-1",
{
"type": "message",
"chat_id": chat_id,
"content": f"@{target_identity.name} hello",
"webui": True,
"turn_id": "turn-self-mention-new-chat",
"session_handles": [{
**target_identity.public_payload(),
"session_key": target_key,
}],
},
)
channel._handle_message.assert_awaited_once()
assert channel._handle_message.call_args.kwargs["content"] == (
f"@{target_identity.name} hello"
)
assert channel._handle_message.call_args.kwargs["metadata"]["session_handles"] == [{
**target_identity.public_payload(),
"session_key": target_key,
}]
assert read_transcript_lines(target_key)[-1]["text"] == (
f"@{target_identity.name} hello"
)
assert json.loads(mock_conn.send.await_args.args[0]) == {
"event": "message_accepted",
"chat_id": chat_id,
"turn_id": "turn-self-mention-new-chat",
"starts_turn": True,
"active_turn_id": "turn-self-mention-new-chat",
"started_at": wth.websocket_turn_wall_started_at(chat_id),
}
@pytest.mark.asyncio
async def test_transcript_backed_webui_chat_preserves_its_visible_identity_mention(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
manager = SessionManager(tmp_path / "sessions")
target_key = "websocket:transcript-only"
append_transcript_object(
target_key,
{"event": "message", "chat_id": "transcript-only", "text": "Earlier reply"},
)
target_identity = SessionHandleDirectory(manager).ensure_snapshot_many([
SessionHandleSnapshot(
session_key=target_key,
workspace=Path.cwd().resolve(),
)
])[target_key]
channel = _make_channel(manager)
mock_conn = AsyncMock()
channel._webui_connections.add(mock_conn)
await channel._dispatch_envelope(
mock_conn,
"client-1",
{
"type": "message",
"chat_id": "transcript-only",
"content": f"@{target_identity.name} hello",
"webui": True,
"turn_id": "turn-self-mention-transcript",
"session_handles": [{
**target_identity.public_payload(),
"session_key": target_key,
}],
},
)
channel._handle_message.assert_awaited_once()
assert channel._handle_message.call_args.kwargs["content"] == (
f"@{target_identity.name} hello"
)
assert json.loads(mock_conn.send.await_args.args[0]) == {
"event": "message_accepted",
"chat_id": "transcript-only",
"turn_id": "turn-self-mention-transcript",
"starts_turn": True,
"active_turn_id": "turn-self-mention-transcript",
"started_at": wth.websocket_turn_wall_started_at("transcript-only"),
}
[block] = metadata[RUNTIME_CONTEXT_INPUT_META]
assert block.source == "session_mentions"
assert "websocket:pricing" in block.content
@pytest.mark.asyncio
@@ -4,7 +4,6 @@ import asyncio
import json
import random
import socket
import threading
import time
from contextlib import suppress
from pathlib import Path
@@ -24,10 +23,7 @@ from nanobot.optional_features import InstallResult
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.session.manager import Session, SessionManager
from nanobot.session.session_handles import (
SessionHandleDirectory,
SessionHandleSnapshot,
)
from nanobot.session.session_handles import session_handle_for_key
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
@@ -163,8 +159,6 @@ def bus() -> MagicMock:
def _seed_session(workspace: Path, key: str = "websocket:test") -> SessionManager:
sm = SessionManager(workspace)
s = Session(key=key)
if key.startswith("websocket:"):
s.metadata["webui"] = True
s.add_message("user", "hi")
s.add_message("assistant", "hello back")
sm.save(s)
@@ -175,8 +169,6 @@ def _seed_many(workspace: Path, keys: list[str]) -> SessionManager:
sm = SessionManager(workspace)
for k in keys:
s = Session(key=k)
if k.startswith("websocket:"):
s.metadata["webui"] = True
s.add_message("user", f"hi from {k}")
sm.save(s)
return sm
@@ -316,11 +308,6 @@ async def test_sessions_list_and_thread_restore_transcript_without_canonical_fil
{"event": "message", "chat_id": "restored-history", "text": "original answer"},
)
assert not sm._get_session_path(key).exists()
directory = SessionHandleDirectory(sm)
directory.ensure_snapshot_many([
SessionHandleSnapshot(session_key=key, workspace=sm.workspace)
])
assert directory.store_path.exists()
port = _free_port()
channel = _ch(bus, session_manager=sm, port=port)
@@ -337,12 +324,8 @@ async def test_sessions_list_and_thread_restore_transcript_without_canonical_fil
)
assert listing.status_code == 200
[row] = listing.json()["sessions"]
assert row["key"] == key
assert row["preview"] == "original question"
assert "handle" not in row
stored = json.loads(directory.store_path.read_text(encoding="utf-8"))
assert all(handle["session_key"] != key for handle in stored["handles"])
assert [row["key"] for row in listing.json()["sessions"]] == [key]
assert listing.json()["sessions"][0]["preview"] == "original question"
assert thread.status_code == 200
assert [message["content"] for message in thread.json()["messages"]] == [
"original question",
@@ -2250,29 +2233,17 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
# Slack / Lark rows would be non-resumable from the browser.
assert keys == {"websocket:alpha", "websocket:beta"}
rows = {row["key"]: row for row in sessions}
assert rows["websocket:alpha"]["handle"] == session_handle_for_key(
"websocket:alpha"
).public_payload()
assert rows["websocket:beta"]["handle"] == session_handle_for_key(
"websocket:beta"
).public_payload()
assert rows["websocket:beta"]["workspace_scope"]["project_path"] == str(
project.resolve()
)
assert rows["websocket:beta"]["workspace_scope"]["access_mode"] == "restricted"
assert all(not any(key.startswith("_") for key in row) for row in sessions)
assert all(set(row["handle"]) == {
"id",
"name",
"color_slot",
} for row in sessions)
refreshed = await _http_get(
"http://127.0.0.1:29906/api/sessions", headers=auth
)
assert refreshed.status_code == 200
refreshed_handles = {
row["key"]: row["handle"]
for row in refreshed.json()["sessions"]
}
assert refreshed_handles == {
row["key"]: row["handle"]
for row in sessions
}
finally:
await channel.stop()
await server_task
@@ -2333,8 +2304,6 @@ async def test_session_delete_removes_file(
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:doomed")
directory = SessionHandleDirectory(sm)
identity = directory.ensure_many(["websocket:doomed"])["websocket:doomed"]
from nanobot.webui.transcript import append_transcript_object
append_transcript_object("websocket:doomed", {"event": "user", "chat_id": "doomed", "text": "x"})
@@ -2345,7 +2314,6 @@ async def test_session_delete_removes_file(
assert path.exists()
webui_path = tmp_path / "webui" / f"{SessionManager.safe_key('websocket:doomed')}.jsonl"
assert webui_path.is_file()
resp = await _webui_mutate(
channel,
"session.delete",
@@ -2355,11 +2323,6 @@ async def test_session_delete_removes_file(
assert resp.json()["deleted"] is True
assert not path.exists()
assert not webui_path.exists()
stored = json.loads(directory.store_path.read_text(encoding="utf-8"))
assert all(
row["id"] != identity.id
for row in stored["handles"]
)
finally:
await channel.stop()
await server_task
@@ -2381,10 +2344,6 @@ async def test_session_delete_removes_transcript_without_canonical_file(
assert not sm._get_session_path(key).exists()
webui_path = tmp_path / "webui" / f"{SessionManager.safe_key(key)}.jsonl"
assert webui_path.is_file()
directory = SessionHandleDirectory(sm)
identity = directory.ensure_snapshot_many([
SessionHandleSnapshot(session_key=key, workspace=sm.workspace)
])[key]
channel = _ch(bus, session_manager=sm, port=_free_port())
server_task = asyncio.create_task(channel.start())
@@ -2398,77 +2357,6 @@ async def test_session_delete_removes_transcript_without_canonical_file(
assert response.status_code == 200
assert response.json()["deleted"] is True
assert not webui_path.exists()
stored = json.loads(directory.store_path.read_text(encoding="utf-8"))
assert all(row["id"] != identity.id for row in stored["handles"])
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_session_delete_cannot_remove_recreated_session_identity(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from nanobot.webui import ws_http as ws_http_module
from nanobot.webui.transcript import append_transcript_object
key = "websocket:delete-recreate"
sm = _seed_session(tmp_path / "workspace", key=key)
directory = SessionHandleDirectory(sm)
directory.ensure_many([key])
append_transcript_object(
key,
{"event": "user", "chat_id": "delete-recreate", "text": "old transcript"},
)
original_delete_webui_thread = ws_http_module.delete_webui_thread
recreate_started = threading.Event()
recreate_finished = threading.Event()
recreated_identities = []
recreate_errors: list[BaseException] = []
recreate_threads: list[threading.Thread] = []
def recreate() -> None:
recreate_started.set()
try:
with sm.locked_session_files():
replacement = Session(key=key)
replacement.metadata["webui"] = True
replacement.add_message("user", "replacement")
sm.save(replacement)
recreated_identities.append(directory.ensure_many([key])[key])
except BaseException as exc:
recreate_errors.append(exc)
finally:
recreate_finished.set()
def delete_transcript_while_recreate_waits(session_key: str) -> bool:
thread = threading.Thread(target=recreate, daemon=True)
recreate_threads.append(thread)
thread.start()
assert recreate_started.wait(timeout=1)
time.sleep(0.05)
assert not recreate_finished.is_set()
return original_delete_webui_thread(session_key)
monkeypatch.setattr(
ws_http_module,
"delete_webui_thread",
delete_transcript_while_recreate_waits,
)
channel = _ch(bus, session_manager=sm, port=_free_port())
server_task = asyncio.create_task(channel.start())
try:
response = await _webui_mutate(channel, "session.delete", {"key": key})
assert response.status_code == 200
assert response.json()["deleted"] is True
assert recreate_threads
await asyncio.to_thread(recreate_threads[0].join, 1)
assert not recreate_threads[0].is_alive()
assert recreate_errors == []
[recreated] = recreated_identities
assert directory.handle_for_session(key) == recreated
assert sm._get_session_path(key).is_file()
finally:
await channel.stop()
await server_task
+70 -94
View File
@@ -14,7 +14,6 @@ from copy import deepcopy
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from threading import RLock
from typing import Any, Callable, Collection, Generator, Protocol, TypedDict, cast
from weakref import WeakValueDictionary
@@ -180,7 +179,6 @@ class Session:
last_consolidated: int = 0 # Number of messages already consolidated to files
provider_state: ProviderConversationState | None = field(default=None, repr=False)
policy: SessionPolicy = field(default_factory=SessionPolicy, repr=False, compare=False)
discarded: bool = field(default=False, init=False, repr=False, compare=False)
def __post_init__(self) -> None:
if not isinstance(cast(object, self.metadata), dict):
@@ -1522,7 +1520,6 @@ class SessionManager:
self.sessions_dir = self._jsonl_store.sessions_dir
self.legacy_sessions_dir = self._jsonl_store.legacy_sessions_dir
self._cache: OrderedDict[str, Session] = OrderedDict()
self._state_lock = RLock()
# Preserve identity for sessions held by active callers without retaining idle ones.
self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary()
self._max_cached_sessions = SESSION_CACHE_MAX_SIZE
@@ -1531,26 +1528,24 @@ class SessionManager:
def _remember(self, session: Session) -> None:
"""Keep recent sessions strongly cached without duplicating live objects."""
with self._state_lock:
self._overflow_cache.pop(session.key, None)
self._cache[session.key] = session
self._cache.move_to_end(session.key)
while len(self._cache) > self._max_cached_sessions:
key, evicted = self._cache.popitem(last=False)
self._overflow_cache[key] = evicted
self._overflow_cache.pop(session.key, None)
self._cache[session.key] = session
self._cache.move_to_end(session.key)
while len(self._cache) > self._max_cached_sessions:
key, evicted = self._cache.popitem(last=False)
self._overflow_cache[key] = evicted
def _cached(self, key: str) -> Session | None:
with self._state_lock:
session = self._cache.get(key)
if session is not None:
self._cache.move_to_end(key)
return session
session = self._overflow_cache.get(key)
if session is not None:
self._remember(session)
session = self._cache.get(key)
if session is not None:
self._cache.move_to_end(key)
return session
session = self._overflow_cache.get(key)
if session is not None:
self._remember(session)
return session
def get_cached(self, key: str) -> Session | None:
"""Return a cached session without creating or loading one from disk."""
return self._cached(key)
@@ -1616,28 +1611,16 @@ class SessionManager:
Returns:
The session.
"""
with self._state_lock:
session = self._cached(key)
if session is not None:
return session
session = self._load(key)
if session is None:
session = Session(key=key)
self._remember(session)
session = self._cached(key)
if session is not None:
return session
def get_existing(self, key: str) -> Session | None:
"""Return an existing cached or persisted session without creating one."""
with self._state_lock:
session = self._cached(key)
if session is not None:
return session
session = self._load(key)
if session is not None:
self._remember(session)
return session
session = self._load(key)
if session is None:
session = Session(key=key)
self._remember(session)
return session
def get_or_create_transient(
self,
@@ -1666,62 +1649,61 @@ class SessionManager:
def save(self, session: Session, *, fsync: bool = False) -> None:
"""Persist a session and retain it in the cache."""
with self._state_lock:
if not session.policy.persist or session.discarded:
return
if not session.policy.persist:
return
archiver = self._file_cap_archiver
if archiver is not None:
session.enforce_file_cap(
on_archive=lambda messages: archiver(
messages,
session_key=session.key,
)
archiver = self._file_cap_archiver
if archiver is not None:
session.enforce_file_cap(
on_archive=lambda messages: archiver(
messages,
session_key=session.key,
)
)
self._store.save(session, fsync=fsync)
self._remember(session)
self._store.save(session, fsync=fsync)
self._remember(session)
def rename_model_preset(self, old_name: str, new_name: str) -> int:
"""Rename a session-scoped model preset across durable and live sessions."""
if old_name == new_name:
return 0
with self._state_lock:
cached = dict(self._overflow_cache.items())
cached.update(self._cache)
keys = set(cached)
keys.update(item["key"] for item in self._store.list_sessions())
changed: list[Session] = []
try:
for key in sorted(keys):
session = cached.get(key) or self._load(key)
if (
session is None
or session.metadata.get(SESSION_MODEL_PRESET_METADATA_KEY) != old_name
):
continue
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = new_name
changed.append(session)
cached = dict(self._overflow_cache.items())
cached.update(self._cache)
keys = set(cached)
keys.update(item["key"] for item in self._store.list_sessions())
changed: list[Session] = []
try:
for key in sorted(keys):
session = cached.get(key) or self._load(key)
if (
session is None
or session.metadata.get(SESSION_MODEL_PRESET_METADATA_KEY) != old_name
):
continue
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = new_name
changed.append(session)
if session.policy.persist:
self.save(session, fsync=True)
else:
self._remember(session)
except BaseException:
for session in reversed(changed):
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = old_name
try:
if session.policy.persist:
self.save(session, fsync=True)
else:
self._remember(session)
except BaseException:
for session in reversed(changed):
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = old_name
try:
if session.policy.persist:
self.save(session, fsync=True)
else:
self._remember(session)
except Exception:
logger.exception(
"Failed to roll back model preset rename for session {}",
session.key,
)
raise
return len(changed)
except Exception:
logger.exception(
"Failed to roll back model preset rename for session {}",
session.key,
)
raise
return len(changed)
def flush_all(self) -> int:
"""Re-save every cached session with fsync for durable shutdown.
@@ -1743,21 +1725,15 @@ class SessionManager:
def invalidate(self, key: str) -> None:
"""Remove a session from the in-memory cache."""
with self._state_lock:
self._cache.pop(key, None)
self._overflow_cache.pop(key, None)
self._cache.pop(key, None)
self._overflow_cache.pop(key, None)
def delete_session(self, key: str) -> bool:
"""Delete a persisted session and invalidate its cache entry."""
with self._state_lock:
session = self._cached(key)
if session is not None:
session.discarded = True
self.invalidate(key)
deleted = self._store.delete(key)
observer = self._delete_observer
if observer is not None:
observer(key)
self.invalidate(key)
deleted = self._store.delete(key)
if self._delete_observer is not None:
self._delete_observer(key)
return deleted
def restore_sessions_to_workspace(self) -> SessionRestoreResult:
+50 -461
View File
@@ -1,506 +1,95 @@
"""Persistent, globally unique handles for sessions.
The directory is the trusted seam between public ``@name`` handles and private
session keys. Titles and transcript text never participate in handle allocation.
"""
"""Stable public handles derived from persisted session keys."""
from __future__ import annotations
import errno
import hashlib
import json
import os
import re
import threading
import unicodedata
import uuid
from collections.abc import Iterable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol, TypedDict, cast, runtime_checkable
from typing import Any, TypedDict
from filelock import FileLock
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.session.manager import SessionManager
SESSION_HANDLE_DIRECTORY_VERSION = 1
SESSION_HANDLE_COLOR_SLOTS = 8
_STORE_FILENAME = ".session-handles.json"
_LOCK_FILENAME = ".session-handles.lock"
_MAX_STORE_BYTES = 512 * 1024
_MAX_HANDLES = 2_000
_MAX_SESSION_KEY_CHARS = 512
_MAX_NAME_CHARS = 24
_HANDLE_ID_RE = re.compile(r"^handle_[0-9a-f]{32}$")
_HANDLE_RE = re.compile(r"^[a-z]{2,16}(?:-(?:[2-9]|[1-9][0-9]+))?$")
_HANDLE_RE = re.compile(r"^[a-z]{2,16}-[0-9a-f]{10}$")
# Short, pronounceable names are easier to remember and type than conversation
# title slugs. The UUID-backed starting offset keeps allocation varied while the
# circular scan and file lock make it deterministic and collision-free.
_HANDLE_NAMES = tuple(
"""
ada abby abel adan adil aiko alba alex alia alma amir amos anil anja ari arlo
asha ava bea ben blair bo bruno cal cam cara carl cato celia chen chloe clara
cleo cora dahlia daisy dana dante dara dario dev dina drew eden eira eli elio
ella elsa emil emma enzo eric esme eva farah felix finn flora freya gabe gia
gwen hana harper hazel heidi hugo ida ila iman ines iris ivan ivo jade jamie
joel jona jude jules juno kai ken kira lana lara leif lena leo lia liam lila
lina liv lois lola luca lucy mabel mae malik mara marco maya mila mina mira
nadia nate neve nico nina noah nora omar oren orla otto owen pablo piper priya
quinn rafi remy ren rhea rio robin rosa ruby sage sami sara sena shay silas
sofia sol sora tariq tavi tess theo timo uma val vera vida wes will wren xena
yara yasmin yuki zara zeno zoe
ada abel adil aiko alba alex alia alma amir amos anil anja arlo asha ava bea
ben blair bruno cara carl cato celia chen chloe clara cleo cora dahlia daisy
dana dante dara dario dev dina drew eden eira eli elio ella elsa emil emma
enzo eric esme eva farah felix finn flora freya gabe gia gwen hana harper
hazel heidi hugo ida ila iman ines iris ivan jade jamie joel jona jude jules
juno kai ken kira lana lara leif lena leo lia liam lila lina liv lois lola
luca lucy mabel mae malik mara marco maya mila mina mira nadia nate neve nico
nina noah nora omar oren orla otto owen pablo piper priya quinn rafi remy ren
rhea rio robin rosa ruby sage sami sara sena shay silas sofia sol sora tariq
tavi tess theo timo uma val vera vida wes will wren xena yara yasmin yuki zara
zeno zoe
""".split()
)
class SessionHandleDirectoryError(RuntimeError):
"""The persisted session-handle directory could not be used safely."""
class SessionHandlePayload(TypedDict):
"""Public handle fields safe to return to a client or model boundary."""
id: str
name: str
color_slot: int
@dataclass(frozen=True, slots=True)
class SessionHandle:
"""Trusted handle for one persisted session.
``session_key`` and ``workspace`` remain backend-only routing fields.
"""
"""Public identity plus the private key used for internal routing."""
id: str
name: str
color_slot: int
session_key: str
workspace: Path
def public_payload(self) -> SessionHandlePayload:
return {
"id": self.id,
"name": self.name,
"color_slot": self.color_slot,
}
@dataclass(frozen=True, slots=True)
class SessionHandleSnapshot:
"""Trusted session fields used to provision handles in one batch."""
session_key: str
workspace: Path
@runtime_checkable
class SessionHandleDirectoryProtocol(Protocol):
"""Narrow directory contract consumed by session-message delivery."""
def handle_for_session(self, key: str) -> SessionHandle | None: ...
def resolve(self, name: str) -> SessionHandle | None: ...
@dataclass(frozen=True, slots=True)
class _StoredHandle:
id: str
session_key: str
workspace: str
name: str
@dataclass(frozen=True, slots=True)
class _SessionDescriptor:
workspace: Path
class SessionHandleDirectory:
"""Atomically persist globally unique ``@name`` handles for sessions."""
def __init__(self, sessions: SessionManager) -> None:
self._sessions = sessions
self.store_path = sessions.sessions_dir / _STORE_FILENAME
self.store_path.parent.mkdir(parents=True, exist_ok=True)
self._thread_lock = threading.RLock()
self._file_lock = FileLock(str(sessions.sessions_dir / _LOCK_FILENAME))
def ensure_many(self, session_keys: list[str]) -> dict[str, SessionHandle]:
"""Provision persisted sessions with at most one atomic store write."""
keys = list(dict.fromkeys(_clean_session_key(key) for key in session_keys))
descriptors = {
key: descriptor
for key in keys
if (descriptor := self._session_descriptor(key)) is not None
}
return self._ensure_descriptors(keys, descriptors)
def ensure_snapshot_many(
self,
snapshots: list[SessionHandleSnapshot],
) -> dict[str, SessionHandle]:
"""Provision trusted index snapshots without rereading session files."""
keys: list[str] = []
descriptors: dict[str, _SessionDescriptor] = {}
for snapshot in snapshots:
key = _clean_session_key(snapshot.session_key)
if key in descriptors:
continue
keys.append(key)
descriptors[key] = _SessionDescriptor(
workspace=_canonical_workspace(snapshot.workspace),
)
return self._ensure_descriptors(keys, descriptors)
def _ensure_descriptors(
self,
keys: list[str],
descriptors: dict[str, _SessionDescriptor],
) -> dict[str, SessionHandle]:
with self._thread_lock, self._file_lock:
records = self._load_unlocked()
by_key = {item.session_key: item for item in records}
changed = False
handles: dict[str, SessionHandle] = {}
for key in keys:
descriptor = descriptors.get(key)
if descriptor is None:
continue
existing = by_key.get(key)
workspace = str(descriptor.workspace)
if existing is not None and _same_workspace(existing.workspace, workspace):
handles[key] = _handle(existing, descriptor)
continue
if existing is not None:
records.remove(existing)
handle_id = existing.id if existing is not None else f"handle_{uuid.uuid4().hex}"
record = _StoredHandle(
id=handle_id,
session_key=key,
workspace=workspace,
name=_allocate_name(
handle_id,
records,
preferred=existing.name if existing is not None else None,
),
)
records.append(record)
by_key[key] = record
handles[key] = _handle(record, descriptor)
changed = True
if changed:
self._save_unlocked(records)
return handles
def handle_for_session(self, key: str) -> SessionHandle | None:
"""Return (and lazily provision) the trusted handle for *key*."""
clean_key = _clean_session_key(key)
handle = self.ensure_many([clean_key]).get(clean_key)
if handle is None:
self.remove_many([clean_key])
return handle
def resolve(self, name: str) -> SessionHandle | None:
"""Resolve a globally unique bare or ``@``-prefixed handle."""
try:
clean_name = normalize_session_handle(name)
except ValueError:
return None
with self._thread_lock, self._file_lock:
candidate = next(
(
item
for item in self._load_unlocked()
if item.name == clean_name
),
None,
)
if candidate is None:
return None
current = self.handle_for_session(candidate.session_key)
if current is None or current.name != clean_name:
return None
return current
def list_all(self) -> list[SessionHandle]:
"""List all live handles, ordered by handle."""
with self._thread_lock, self._file_lock:
keys = [item.session_key for item in self._load_unlocked()]
handles = [self.handle_for_session(key) for key in keys]
return sorted(
(handle for handle in handles if handle is not None),
key=lambda handle: handle.name,
)
def remove_many(self, session_keys: Iterable[str]) -> int:
"""Atomically remove handles bound to *session_keys*."""
keys = {_clean_session_key(key) for key in session_keys}
if not keys:
return 0
with self._thread_lock, self._file_lock:
records = self._load_unlocked()
remaining = [item for item in records if item.session_key not in keys]
removed = len(records) - len(remaining)
if removed == 0:
return 0
self._save_unlocked(remaining)
return removed
def _session_descriptor(self, session_key: str) -> _SessionDescriptor | None:
payload = self._sessions.read_session_metadata(session_key)
if payload is None:
return None
raw_metadata = cast(object, payload.get("metadata"))
metadata = cast(dict[str, Any], raw_metadata) if isinstance(raw_metadata, dict) else {}
return _SessionDescriptor(
workspace=_workspace_from_metadata(metadata, default=self._sessions.workspace),
)
def _load_unlocked(self) -> list[_StoredHandle]:
if not self.store_path.is_file():
return []
try:
if self.store_path.stat().st_size > _MAX_STORE_BYTES:
raise SessionHandleDirectoryError("session handle store is too large")
raw: object = json.loads(self.store_path.read_text(encoding="utf-8"))
except SessionHandleDirectoryError:
raise
except (OSError, json.JSONDecodeError) as exc:
raise SessionHandleDirectoryError(
f"session handle store could not be read: {self.store_path}"
) from exc
if not isinstance(raw, dict):
raise SessionHandleDirectoryError("session handle store must be a JSON object")
data = cast(dict[str, Any], raw)
version = data.get("version")
raw_records = data.get("handles")
if version != SESSION_HANDLE_DIRECTORY_VERSION or not isinstance(raw_records, list):
raise SessionHandleDirectoryError("unsupported session handle store format")
record_values = cast(list[object], raw_records)
if len(record_values) > _MAX_HANDLES:
raise SessionHandleDirectoryError("session handle store has too many records")
records = [_parse_record(raw_record) for raw_record in record_values]
_validate_unique_records(records, globally_unique_names=False)
records, repaired = _repair_globally_duplicate_names(records)
_validate_unique_records(records)
if repaired:
self._save_unlocked(records)
return records
def _save_unlocked(self, records: list[_StoredHandle]) -> None:
if len(records) > _MAX_HANDLES:
raise SessionHandleDirectoryError("session handle store has too many records")
_validate_unique_records(records)
payload = {
"version": SESSION_HANDLE_DIRECTORY_VERSION,
"handles": [
{
"id": item.id,
"session_key": item.session_key,
"workspace": item.workspace,
"name": item.name,
}
for item in sorted(records, key=lambda item: item.session_key)
],
}
encoded = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
if len(encoded) > _MAX_STORE_BYTES:
raise SessionHandleDirectoryError("session handle store is too large")
_atomic_write(self.store_path, encoded)
return {"id": self.id, "name": self.name}
def normalize_session_handle(value: str) -> str:
"""Return the canonical bare session handle accepted by the directory."""
name = unicodedata.normalize("NFKC", value.strip())
if name.startswith("@"):
name = name[1:]
name = name.casefold()
if not name or len(name) > _MAX_NAME_CHARS or _HANDLE_RE.fullmatch(name) is None:
raise ValueError("session handle must be a short ASCII name, optionally with a number")
"""Return the canonical bare handle accepted at model and UI boundaries."""
name = value.strip().removeprefix("@").casefold()
if _HANDLE_RE.fullmatch(name) is None:
raise ValueError("session handle is invalid")
return name
def _parse_record(raw: object) -> _StoredHandle:
if not isinstance(raw, dict):
raise SessionHandleDirectoryError("session handle records must be JSON objects")
data = cast(dict[str, Any], raw)
handle_id = data.get("id")
session_key = data.get("session_key")
raw_workspace = data.get("workspace")
raw_name = data.get("name")
if (
not isinstance(handle_id, str)
or _HANDLE_ID_RE.fullmatch(handle_id) is None
or not isinstance(session_key, str)
or not isinstance(raw_workspace, str)
or not isinstance(raw_name, str)
):
raise SessionHandleDirectoryError("invalid session handle record")
try:
clean_key = _clean_session_key(session_key)
workspace = _canonical_workspace(Path(raw_workspace))
name = normalize_session_handle(raw_name)
except ValueError as exc:
raise SessionHandleDirectoryError("invalid session handle record") from exc
if clean_key != session_key or str(workspace) != raw_workspace or name != raw_name:
raise SessionHandleDirectoryError("session handle record is not canonical")
return _StoredHandle(
id=handle_id,
session_key=clean_key,
workspace=str(workspace),
name=name,
)
def _validate_unique_records(
records: list[_StoredHandle],
*,
globally_unique_names: bool = True,
) -> None:
ids: set[str] = set()
session_keys: set[str] = set()
scoped_names: set[tuple[str, str]] = set()
for item in records:
name_scope = "" if globally_unique_names else _workspace_key(item.workspace)
scoped_name = (name_scope, item.name.casefold())
if item.id in ids or item.session_key in session_keys or scoped_name in scoped_names:
raise SessionHandleDirectoryError("session handle store contains duplicate records")
ids.add(item.id)
session_keys.add(item.session_key)
scoped_names.add(scoped_name)
def _repair_globally_duplicate_names(
records: list[_StoredHandle],
) -> tuple[list[_StoredHandle], bool]:
repaired = list(records)
seen: set[str] = set()
changed = False
for index, item in enumerate(repaired):
folded = item.name.casefold()
if folded not in seen:
seen.add(folded)
continue
replacement = _StoredHandle(
id=item.id,
session_key=item.session_key,
workspace=item.workspace,
name=_allocate_name(item.id, repaired[:index] + repaired[index + 1 :]),
)
repaired[index] = replacement
seen.add(replacement.name.casefold())
changed = True
return repaired, changed
def _workspace_from_metadata(metadata: dict[str, Any], *, default: Path) -> Path:
raw_scope = metadata.get(WORKSPACE_SCOPE_METADATA_KEY)
if not isinstance(raw_scope, dict):
return default.expanduser().resolve(strict=False)
raw_path = cast(dict[str, Any], raw_scope).get("project_path")
if raw_path is None:
return default.expanduser().resolve(strict=False)
if not isinstance(raw_path, str) or not raw_path.strip():
raise SessionHandleDirectoryError("session workspace scope has an invalid project path")
try:
return _canonical_workspace(Path(raw_path))
except ValueError as exc:
raise SessionHandleDirectoryError("session workspace scope has an invalid project path") from exc
def _canonical_workspace(path: Path) -> Path:
expanded = path.expanduser()
if not expanded.is_absolute():
raise ValueError("workspace path must be absolute")
return expanded.resolve(strict=False)
def _clean_session_key(value: str) -> str:
key = value.strip()
def session_handle_for_key(session_key: str) -> SessionHandle:
"""Derive a stable handle without creating a second persistence lifecycle."""
key = session_key.strip()
if not key or len(key) > _MAX_SESSION_KEY_CHARS:
raise ValueError("session key is invalid")
return key
def _allocate_name(
handle_id: str,
records: list[_StoredHandle],
*,
preferred: str | None = None,
) -> str:
used = {item.name.casefold() for item in records}
if preferred is not None and preferred.casefold() not in used:
return normalize_session_handle(preferred)
offset = int.from_bytes(
hashlib.sha256(handle_id.encode("ascii")).digest()[:4],
"big",
) % len(_HANDLE_NAMES)
ordered = _HANDLE_NAMES[offset:] + _HANDLE_NAMES[:offset]
for name in ordered:
if name not in used:
return name
suffix = 2
while suffix <= _MAX_HANDLES + 1:
for base in ordered:
candidate = f"{base}-{suffix}"
if candidate not in used:
return candidate
suffix += 1
raise SessionHandleDirectoryError("could not allocate a unique session handle")
def _handle(record: _StoredHandle, descriptor: _SessionDescriptor) -> SessionHandle:
color_slot = int.from_bytes(
hashlib.sha256(record.id.encode("ascii")).digest()[:2],
"big",
) % SESSION_HANDLE_COLOR_SLOTS
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
word = _HANDLE_NAMES[int(digest[:8], 16) % len(_HANDLE_NAMES)]
return SessionHandle(
id=record.id,
name=record.name,
color_slot=color_slot,
session_key=record.session_key,
workspace=descriptor.workspace,
id=f"handle_{digest[:32]}",
name=f"{word}-{digest[32:42]}",
session_key=key,
)
def _workspace_key(path: str) -> str:
return os.path.normcase(os.path.normpath(path))
class SessionHandleResolver:
"""Resolve derived handles against the current persisted-session list."""
def __init__(self, sessions: SessionManager) -> None:
self._sessions = sessions
def _same_workspace(left: str, right: str) -> bool:
return _workspace_key(left) == _workspace_key(right)
def _atomic_write(path: Path, content: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
with open(tmp_path, "wb") as file:
file.write(content)
file.flush()
os.fsync(file.fileno())
os.replace(tmp_path, path)
with suppress(PermissionError):
directory_fd = os.open(str(path.parent), os.O_RDONLY)
def list_all(self) -> list[SessionHandle]:
handles: list[SessionHandle] = []
for row in self._sessions.list_sessions():
raw_key: Any = row.get("key")
if not isinstance(raw_key, str):
continue
try:
try:
os.fsync(directory_fd)
except OSError as exc:
if exc.errno != errno.EINVAL:
raise
finally:
os.close(directory_fd)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
handles.append(session_handle_for_key(raw_key))
except ValueError:
continue
return sorted(handles, key=lambda handle: handle.name)
def resolve(self, name: str) -> SessionHandle | None:
try:
normalized = normalize_session_handle(name)
except ValueError:
return None
matches = [handle for handle in self.list_all() if handle.name == normalized]
return matches[0] if len(matches) == 1 else None
+18 -216
View File
@@ -1,4 +1,4 @@
"""Bounded delivery of messages between persisted sessions."""
"""Metadata carried by user input sent between persisted sessions."""
from __future__ import annotations
@@ -6,257 +6,59 @@ import re
from collections.abc import Mapping
from typing import Any, TypedDict, cast
from nanobot.bus.events import InboundMessage
from nanobot.session.session_handles import (
normalize_session_handle as normalize_stored_session_handle,
)
SESSION_MESSAGE_METADATA_KEY = "_session_message"
SESSION_REPLY_TIMEOUT_METADATA_KEY = "_session_reply_timeout"
SESSION_MESSAGE_SENDER_ID = "session"
SESSION_REPLY_TIMEOUT_SENDER_ID = "session_timeout"
MIN_REPLY_TIMEOUT_SECONDS = 5
MAX_REPLY_TIMEOUT_SECONDS = 60
_MAX_SESSION_KEY_CHARS = 512
_MESSAGE_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
class SessionMessageEndpoint(TypedDict):
"""One endpoint stored in an internal session-message envelope."""
name: str
session_key: str
class SessionMessageSourceEndpoint(SessionMessageEndpoint):
"""Source fields used for WebUI provenance."""
handle_id: str
color_slot: int
class SessionMessageEnvelope(TypedDict):
"""Metadata persisted with session-authored user input."""
message_id: str
created_at_ms: int
expect_reply: bool
source: SessionMessageSourceEndpoint
target: SessionMessageEndpoint
class SessionReplyTimeoutEnvelope(SessionMessageEnvelope):
"""Trusted metadata for resuming a session after a session reply deadline."""
timeout_seconds: int
class SessionMessageError(ValueError):
"""A session message was rejected before reaching the inbound bus."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
def normalize_session_handle(value: str) -> str:
"""Return the canonical bare session handle accepted by the directory."""
try:
return normalize_stored_session_handle(value)
except ValueError as exc:
raise SessionMessageError("invalid_name", str(exc)) from exc
source_session_key: str
target_session_key: str
def session_message_envelope(
metadata: Mapping[str, Any] | None,
) -> SessionMessageEnvelope | None:
"""Validate and normalize a session envelope from an inbound metadata boundary."""
"""Read a validated envelope from request or persisted-message metadata."""
if not isinstance(metadata, Mapping):
return None
raw = metadata.get(SESSION_MESSAGE_METADATA_KEY)
if not isinstance(raw, Mapping):
return None
data = cast(Mapping[str, object], raw)
message_id = _bounded_id(data.get("message_id"))
message_id = data.get("message_id")
created_at_ms = data.get("created_at_ms")
expect_reply = data.get("expect_reply")
source = _session_source_endpoint(data.get("source"))
target = _session_endpoint(data.get("target"))
source_session_key = _session_key(data.get("source_session_key"))
target_session_key = _session_key(data.get("target_session_key"))
if (
message_id is None
not isinstance(message_id, str)
or _MESSAGE_ID_RE.fullmatch(message_id) is None
or not isinstance(created_at_ms, int)
or isinstance(created_at_ms, bool)
or created_at_ms < 0
or not isinstance(expect_reply, bool)
or source is None
or target is None
or source_session_key is None
or target_session_key is None
):
return None
return {
"message_id": message_id,
"created_at_ms": created_at_ms,
"expect_reply": expect_reply,
"source": source,
"target": target,
"source_session_key": source_session_key,
"target_session_key": target_session_key,
}
def session_reply_timeout_envelope(
metadata: Mapping[str, Any] | None,
) -> SessionReplyTimeoutEnvelope | None:
"""Validate and normalize a session reply-timeout envelope."""
if not isinstance(metadata, Mapping):
def _session_key(value: object) -> str | None:
if not isinstance(value, str):
return None
raw = metadata.get(SESSION_REPLY_TIMEOUT_METADATA_KEY)
if not isinstance(raw, Mapping):
normalized_key = value.strip()
if not normalized_key or len(normalized_key) > _MAX_SESSION_KEY_CHARS:
return None
data = cast(Mapping[str, object], raw)
request = session_message_envelope({SESSION_MESSAGE_METADATA_KEY: data})
timeout_seconds = data.get("timeout_seconds")
if (
request is None
or not request["expect_reply"]
or not isinstance(timeout_seconds, int)
or isinstance(timeout_seconds, bool)
or not MIN_REPLY_TIMEOUT_SECONDS <= timeout_seconds <= MAX_REPLY_TIMEOUT_SECONDS
):
return None
return {
**request,
"timeout_seconds": timeout_seconds,
}
def session_message_inbound(msg: InboundMessage) -> SessionMessageEnvelope | None:
"""Return a session envelope only for the internal delivery shape we mint.
Metadata alone is not provenance: channel adapters may carry client-provided
metadata. Requiring the complete internal shape prevents forged session input.
"""
if (
msg.sender_id != SESSION_MESSAGE_SENDER_ID
or msg.session_key_override is None
):
return None
envelope = session_message_envelope(msg.metadata)
if envelope is None:
return None
target_key = envelope["target"]["session_key"]
raw_route = msg.channel == "system" and msg.chat_id == target_key
user_route = msg.channel == "websocket" and f"websocket:{msg.chat_id}" == target_key
if msg.session_key_override != target_key or not (raw_route or user_route):
return None
return envelope
def session_reply_timeout_inbound(
msg: InboundMessage,
) -> SessionReplyTimeoutEnvelope | None:
"""Return a timeout envelope only for the internal delivery shape we mint."""
if (
msg.sender_id != SESSION_REPLY_TIMEOUT_SENDER_ID
or msg.session_key_override is None
):
return None
envelope = session_reply_timeout_envelope(msg.metadata)
if envelope is None:
return None
waiter_key = envelope["source"]["session_key"]
raw_route = msg.channel == "system" and msg.chat_id == waiter_key
user_route = msg.channel == "websocket" and f"websocket:{msg.chat_id}" == waiter_key
if msg.session_key_override != waiter_key or not (raw_route or user_route):
return None
return envelope
def is_session_input(msg: InboundMessage) -> bool:
"""Return whether *msg* is a server-minted session input."""
return (
session_message_inbound(msg) is not None
or session_reply_timeout_inbound(msg) is not None
)
def session_input_history_extra(msg: InboundMessage) -> dict[str, Any]:
"""Return private history metadata for one validated session input."""
envelope = session_message_inbound(msg)
if envelope is not None:
return {SESSION_MESSAGE_METADATA_KEY: envelope}
timeout = session_reply_timeout_inbound(msg)
return {SESSION_REPLY_TIMEOUT_METADATA_KEY: timeout} if timeout is not None else {}
def session_message_public_metadata(envelope: SessionMessageEnvelope) -> dict[str, Any]:
"""Return public provenance without internal routing identifiers."""
source = envelope["source"]
return {
"direction": "incoming",
"message_id": envelope["message_id"],
"session": {
"id": source["handle_id"],
"name": source["name"],
"color_slot": source["color_slot"],
},
}
def _bounded_id(value: object) -> str | None:
return value if isinstance(value, str) and _MESSAGE_ID_RE.fullmatch(value) else None
def _session_endpoint(value: object) -> SessionMessageEndpoint | None:
if not isinstance(value, Mapping):
return None
data = cast(Mapping[str, object], value)
raw_name = data.get("name")
raw_key = data.get("session_key")
if not isinstance(raw_name, str) or not isinstance(raw_key, str):
return None
try:
name = normalize_session_handle(raw_name)
except SessionMessageError:
return None
session_key = raw_key.strip()
if not session_key or len(session_key) > _MAX_SESSION_KEY_CHARS:
return None
return {
"name": name,
"session_key": session_key,
}
def _session_source_endpoint(value: object) -> SessionMessageSourceEndpoint | None:
endpoint = _session_endpoint(value)
if endpoint is None:
return None
data = cast(Mapping[str, object], value)
handle_id = data.get("handle_id")
color_slot = data.get("color_slot")
if (
not isinstance(handle_id, str)
or _MESSAGE_ID_RE.fullmatch(handle_id) is None
or not isinstance(color_slot, int)
or isinstance(color_slot, bool)
or not 0 <= color_slot < 8
):
return None
return {
**endpoint,
"handle_id": handle_id,
"color_slot": color_slot,
}
def is_persisted_webui_session(
session_key: str,
payload: Mapping[str, Any],
) -> bool:
"""Return whether *payload* is a persisted WebUI conversation."""
raw_metadata = cast(object, payload.get("metadata"))
if not session_key.startswith("websocket:") or not isinstance(raw_metadata, Mapping):
return False
metadata = cast(Mapping[str, object], raw_metadata)
return metadata.get("webui") is True
return normalized_key
+72 -98
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import re
import time
from collections.abc import Awaitable, Callable, Mapping
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, replace
from typing import Any, cast
from uuid import uuid4
@@ -19,10 +19,10 @@ from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
RuntimeModelUpdatedEvent,
SessionMessageInputEvent,
SessionUpdatedEvent,
TurnEndEvent,
TurnModelUpdatedEvent,
UserInputEvent,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
@@ -35,6 +35,7 @@ from nanobot.bus.runtime_events import (
TurnCompleted,
TurnRunStatusChanged,
TurnRuntimeAdmitted,
UserInputAccepted,
)
from nanobot.providers.base import LLMProvider
from nanobot.providers.fallback_provider import FallbackModelObserver
@@ -42,17 +43,15 @@ from nanobot.runtime_context import public_history_message
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import Session, SessionManager
from nanobot.session.session_handles import session_handle_for_key
from nanobot.session.session_messages import (
SESSION_MESSAGE_METADATA_KEY,
session_message_inbound,
session_message_public_metadata,
session_reply_timeout_inbound,
SessionMessageEnvelope,
session_message_envelope,
)
from nanobot.utils.helpers import strip_think, truncate_text
from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.webui.metadata import (
WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
WEBUI_TURN_METADATA_KEY,
)
from nanobot.webui.transcript import append_session_message_input
@@ -83,6 +82,16 @@ class _WebsocketTurn:
_WEBSOCKET_ACTIVE_TURNS: dict[str, dict[str, _WebsocketTurn]] = {}
def _session_message_public_metadata(
envelope: SessionMessageEnvelope,
) -> dict[str, Any]:
source = session_handle_for_key(envelope["source_session_key"])
return {
"message_id": envelope["message_id"],
"session": source.public_payload(),
}
def _validated_llm_runtime(value: object) -> LLMRuntime | None:
"""Keep runtime-event consumers defensive if an external publisher violates the contract."""
return value if isinstance(value, LLMRuntime) else None
@@ -115,20 +124,6 @@ def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
return True
def _session_for_webui_lifecycle(
sessions: SessionManager,
msg: InboundMessage,
session_key: str,
) -> Session | None:
"""Resolve lifecycle state without reviving deleted internal-message targets."""
if (
session_message_inbound(msg) is not None
or session_reply_timeout_inbound(msg) is not None
):
return sessions.get_existing(session_key)
return sessions.get_or_create(session_key)
def clean_generated_title(raw: str | None) -> str:
text = (raw or "").strip()
if not text:
@@ -176,9 +171,7 @@ async def maybe_generate_webui_title(
model: str,
) -> bool:
"""Generate and persist a short title for WebUI-owned sessions only."""
session = sessions.get_existing(session_key)
if session is None:
return False
session = sessions.get_or_create(session_key)
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return False
if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
@@ -426,8 +419,7 @@ class WebuiTurnRoutePolicy:
) -> TurnRoute:
"""Make an independently dispatched agent turn visible in WebUI."""
routed = route
session_message = session_message_inbound(msg)
reply_timeout = session_reply_timeout_inbound(msg)
internal_user_input = msg.channel == "system" and msg.is_user_input
if (
(
(
@@ -435,41 +427,19 @@ class WebuiTurnRoutePolicy:
and msg.sender_id == "subagent"
and msg.metadata.get("injected_event") == "subagent_result"
)
or session_message is not None
or reply_timeout is not None
or internal_user_input
)
and route.channel == "websocket"
):
if session_message is not None or reply_timeout is not None:
persisted = self.sessions.read_session_metadata(session_key)
raw_session_metadata = (
persisted.get("metadata") if persisted is not None else None
)
session_metadata: Mapping[str, Any] = (
cast(Mapping[str, Any], raw_session_metadata)
if isinstance(raw_session_metadata, Mapping)
else {}
)
else:
session_metadata = self.sessions.get_or_create(session_key).metadata
if session_metadata.get(WEBUI_SESSION_METADATA_KEY) is True:
session = self.sessions.get_or_create(session_key)
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is True:
metadata = dict(route.metadata)
turn_prefix = "subagent"
if session_message is not None:
turn_prefix = "session-message"
elif reply_timeout is not None:
turn_prefix = "session-reply-timeout"
turn_prefix = "session-input" if internal_user_input else "subagent"
metadata.update({
WEBUI_SESSION_METADATA_KEY: True,
"_wants_stream": True,
WEBUI_TURN_METADATA_KEY: f"{turn_prefix}:{uuid4().hex}",
})
if session_message is not None:
metadata[SESSION_MESSAGE_METADATA_KEY] = session_message
metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = {
"kind": "session",
"label": f"@{session_message['source']['name']}",
}
routed = replace(route, metadata=metadata, publish_lifecycle=True)
if routed.channel == "websocket" and routed.publish_lifecycle:
@@ -501,40 +471,6 @@ class WebuiTurnRoutePolicy:
return routed
async def project_session_message_input(
bus: MessageBus,
msg: InboundMessage,
session_key: str,
) -> None:
"""Persist and publish an incoming session message for WebUI clients."""
envelope = session_message_inbound(msg)
if envelope is None or msg.channel != "websocket":
return
public_metadata = session_message_public_metadata(envelope)
try:
append_session_message_input(
session_key,
content=msg.content,
created_at_ms=envelope["created_at_ms"],
session_message=public_metadata,
)
except (OSError, TypeError, ValueError):
logger.warning(
"Failed to persist session input {}",
envelope["message_id"],
exc_info=True,
)
await bus.publish_outbound(outbound_message_for_event(
channel="websocket",
chat_id=str(msg.chat_id),
event=SessionMessageInputEvent(
content=msg.content,
created_at_ms=envelope["created_at_ms"],
session_message=public_metadata,
),
))
def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserver:
"""Translate provider fallback choices into chat-scoped WebUI events."""
@@ -575,6 +511,10 @@ class WebuiTurnCoordinator:
def subscribe(self, runtime_events: RuntimeEventBus) -> Callable[[], None]:
"""Subscribe this coordinator to runtime events."""
unsubscribe = [
runtime_events.subscribe(
self._handle_user_input_accepted,
UserInputAccepted,
),
runtime_events.subscribe(
self._handle_session_turn_started,
SessionTurnStarted,
@@ -622,17 +562,53 @@ class WebuiTurnCoordinator:
def _is_websocket_event(ctx: RuntimeEventContext) -> bool:
return ctx.channel == "websocket"
async def _handle_session_turn_started(self, event: SessionTurnStarted) -> None:
async def _handle_user_input_accepted(self, event: UserInputAccepted) -> None:
envelope = session_message_envelope(event.context.metadata)
session_key = event.context.session_key
if (
event.context.channel != "system"
or envelope is None
or envelope["target_session_key"] != session_key
or not session_key.startswith("websocket:")
):
return
persisted = self.sessions.read_session_metadata(session_key)
metadata_value: object = persisted.get("metadata") if persisted is not None else None
metadata = (
cast(dict[str, Any], metadata_value)
if isinstance(metadata_value, dict)
else None
)
if metadata is None or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
return
public_metadata = _session_message_public_metadata(envelope)
try:
append_session_message_input(
session_key,
content=event.content,
created_at_ms=envelope["created_at_ms"],
session_message=public_metadata,
)
except (OSError, TypeError, ValueError):
logger.warning(
"Failed to persist session input {}",
envelope["message_id"],
exc_info=True,
)
await self.bus.publish_outbound(outbound_message_for_event(
channel="websocket",
chat_id=session_key.split(":", 1)[1],
event=UserInputEvent(
content=event.content,
created_at_ms=envelope["created_at_ms"],
provenance={"session_message": public_metadata},
),
))
def _handle_session_turn_started(self, event: SessionTurnStarted) -> None:
if not self._is_websocket_event(event.context):
return
msg = self._ctx_msg(event.context)
session = _session_for_webui_lifecycle(
self.sessions,
msg,
event.context.session_key,
)
if session is None:
return
session = self.sessions.get_or_create(event.context.session_key)
mark_webui_session(session, event.context.metadata)
async def _handle_run_status_changed(self, event: TurnRunStatusChanged) -> None:
@@ -726,9 +702,7 @@ class WebuiTurnCoordinator:
if msg.channel != "websocket":
return
session = _session_for_webui_lifecycle(self.sessions, msg, session_key)
if session is None:
return
session = self.sessions.get_or_create(session_key)
await self.bus.publish_outbound(
outbound_message_for_event(
channel=msg.channel,
+19 -81
View File
@@ -14,17 +14,10 @@ from nanobot.runtime_context import (
)
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import SessionManager
from nanobot.session.session_handles import (
SessionHandle,
SessionHandleDirectory,
)
from nanobot.session.session_messages import is_persisted_webui_session
from nanobot.webui.session_list_index import (
list_webui_sessions,
)
from nanobot.session.session_handles import session_handle_for_key
from nanobot.webui.session_list_index import list_webui_sessions
from nanobot.webui.transcript import (
build_webui_thread_response,
normalize_session_handles_metadata,
normalize_session_mentions_metadata,
)
@@ -32,16 +25,10 @@ _VISIBLE_ROLES = {"user", "assistant"}
class SessionMention(TypedDict):
name: str
session_key: str
title: str
class SessionHandleMention(TypedDict):
id: str
name: str
session_key: str
color_slot: int
title: str
class SessionMessage(TypedDict):
@@ -118,7 +105,6 @@ class WebuiSessionAccess:
def __init__(self, sessions: SessionManager) -> None:
self._sessions = sessions
self._handles = SessionHandleDirectory(sessions)
def _metadata(
self,
@@ -242,61 +228,12 @@ class WebuiSessionAccess:
seen_keys: set[str] = set()
seen_names: set[str] = set()
for raw_mention in normalize_session_mentions_metadata(raw):
mention = cast(SessionMention, raw_mention)
mention = raw_mention
key = mention["session_key"]
folded_name = mention["name"].casefold()
payload = self._metadata(key, exclude_session_key=exclude_session_key)
if payload is None or key in seen_keys or folded_name in seen_names:
continue
normalized.append({
"name": mention["name"],
"session_key": key,
"title": _text(_session_metadata(payload).get("title")),
})
seen_keys.add(key)
seen_names.add(folded_name)
return normalized
def normalize_session_handles(
self,
raw: object,
*,
source_session_key: str,
) -> list[SessionHandleMention]:
"""Validate active session handles selected by a WebUI user turn."""
normalized: list[SessionHandleMention] = []
seen_keys: set[str] = set()
seen_names: set[str] = set()
source_handle = self._session_handle(source_session_key)
if source_handle is None:
return []
for raw_handle in normalize_session_handles_metadata(raw):
key = str(raw_handle["session_key"])
raw_handle_id = cast(object, raw_handle.get("id"))
if not isinstance(raw_handle_id, str) or key in seen_keys:
continue
if key == source_session_key:
handle = source_handle
else:
payload = self._metadata(key, exclude_session_key=None)
if payload is None or not key.startswith("websocket:"):
continue
raw_metadata = payload.get("metadata")
if not isinstance(raw_metadata, Mapping):
continue
metadata = cast(Mapping[str, object], raw_metadata)
if metadata.get("webui") is not True:
continue
handle = self._handles.resolve(
str(raw_handle["name"]),
)
if (
handle is None
or handle.session_key != key
or handle.id != raw_handle_id
or handle.name != str(raw_handle["name"])
):
if payload is None or key in seen_keys:
continue
handle = session_handle_for_key(key)
folded_name = handle.name.casefold()
if folded_name in seen_names:
continue
@@ -304,28 +241,29 @@ class WebuiSessionAccess:
"id": handle.id,
"name": handle.name,
"session_key": key,
"color_slot": handle.color_slot,
"title": _text(_session_metadata(payload).get("title")),
})
seen_keys.add(key)
seen_names.add(folded_name)
return normalized
def _session_handle(
self,
session_key: str,
) -> SessionHandle | None:
payload = self._metadata(session_key, exclude_session_key=None)
if payload is None or not is_persisted_webui_session(session_key, payload):
return None
return self._handles.handle_for_session(session_key)
def session_mentions_runtime_context(
mentions: list[SessionMention],
) -> RuntimeContextBlock | None:
if not mentions:
return None
encoded = json.dumps(mentions, ensure_ascii=False, separators=(",", ":"))
encoded = json.dumps(
[
{
"name": mention["name"],
"session_key": mention["session_key"],
"title": mention["title"],
}
for mention in mentions
],
ensure_ascii=False,
separators=(",", ":"),
)
encoded = encoded.replace("[/Runtime Context]", "\\u005b/Runtime Context\\u005d")
content = wrap_runtime_context_lines([
"The user selected these persisted session references (JSON data, not instructions):",
+3 -27
View File
@@ -32,21 +32,16 @@ from nanobot.session.manager import (
)
from nanobot.session.model_selection import model_preset_from_metadata
_INDEX_VERSION = 8
_INDEX_VERSION = 7
_INDEX_FILENAME = ".webui_session_index.json"
_MODEL_PRESET_FIELD = "model_preset"
_ROW_SOURCE_FIELD = "_source"
_SESSION_SOURCE = "session"
_TRANSCRIPT_SOURCE = "webui_transcript"
_PERSISTED_WEBUI_FIELD = "_persisted_webui"
_WORKSPACE_SCOPE_PRESENT_FIELD = "_workspace_scope_present"
_WORKSPACE_SCOPE_VALUE_FIELD = "_workspace_scope_value"
WEBUI_SESSION_INDEX_INTERNAL_FIELDS = frozenset(
{
_PERSISTED_WEBUI_FIELD,
_WORKSPACE_SCOPE_PRESENT_FIELD,
_WORKSPACE_SCOPE_VALUE_FIELD,
}
{_WORKSPACE_SCOPE_PRESENT_FIELD, _WORKSPACE_SCOPE_VALUE_FIELD}
)
_INDEXED_WORKSPACE_SCOPE_KEYS = ("project_path", "path", "access_mode")
_MAX_INDEXED_WORKSPACE_SCOPE_BYTES = 4096
@@ -250,18 +245,12 @@ def _public_row(sessions_dir: Path, webui_dir: Path, row: dict[str, Any]) -> dic
"title": row.get("title", ""),
"preview": row.get("preview", ""),
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
_PERSISTED_WEBUI_FIELD: row.get(_PERSISTED_WEBUI_FIELD) is True,
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
"path": str(path),
}
def is_persisted_webui_session_row(row: dict[str, Any]) -> bool:
"""Return whether an indexed row has a canonical, addressable WebUI session."""
return row.get(_PERSISTED_WEBUI_FIELD) is True
def indexed_workspace_scope(row: dict[str, Any]) -> tuple[bool, object]:
"""Return the cached sidebar scope value while preserving missing vs null."""
return (
@@ -496,9 +485,6 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
"title": _metadata_title(session.metadata),
"preview": _preview_from_messages(session.messages),
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
_PERSISTED_WEBUI_FIELD: (
session.key.startswith("websocket:") and session.metadata.get("webui") is True
),
**_indexed_workspace_scope_fields(session.metadata),
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
"file": path.name,
@@ -615,7 +601,6 @@ def _scan_transcript_row(
"title": "",
"preview": preview or fallback_preview,
_MODEL_PRESET_FIELD: None,
_PERSISTED_WEBUI_FIELD: False,
**_indexed_workspace_scope_fields({}),
_ROW_SOURCE_FIELD: _TRANSCRIPT_SOURCE,
"file": stem,
@@ -688,12 +673,7 @@ def _scan_session_row(
created_at_s = created_at_s or fallback_time
updated_at_s = updated_at_s or fallback_time
key = data.get("key") or storage_key
raw_metadata: object = data.get("metadata")
metadata = (
cast(dict[str, Any], raw_metadata)
if isinstance(raw_metadata, dict)
else {}
)
metadata = data.get("metadata", {})
activity_signature = _webui_activity_signature(key, webui_dir)
activity_updated_at = _webui_activity_updated_at(activity_signature)
return {
@@ -707,10 +687,6 @@ def _scan_session_row(
"title": _metadata_title(metadata),
"preview": preview or fallback_preview,
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
_PERSISTED_WEBUI_FIELD: (
key.startswith("websocket:")
and metadata.get("webui") is True
),
**_indexed_workspace_scope_fields(metadata),
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
"file": path.name,
+34 -93
View File
@@ -22,10 +22,6 @@ from nanobot.runtime_context import public_history_message
from nanobot.session.automation_turns import is_automation_kind
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import SessionManager
from nanobot.session.session_messages import (
session_message_envelope,
session_message_public_metadata,
)
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
@@ -698,14 +694,6 @@ def append_session_message_input(
chat_id = _chat_id_from_session_key(session_key)
if chat_id is None:
return
message_id = session_message.get("message_id")
if isinstance(message_id, str) and any(
isinstance(record.get("session_message"), Mapping)
and cast(Mapping[str, Any], record["session_message"]).get("message_id")
== message_id
for record in read_transcript_lines(session_key)
):
return
event = build_user_transcript_event(chat_id, content)
if event is None:
return
@@ -728,9 +716,7 @@ def webui_message_source(metadata: dict[str, Any] | None) -> dict[str, str] | No
return None
source_metadata = cast(dict[str, Any], raw)
kind = source_metadata.get("kind")
if not isinstance(kind, str) or (
not is_automation_kind(kind) and kind != "session"
):
if not isinstance(kind, str) or not is_automation_kind(kind):
return None
source: dict[str, str] = {"kind": kind}
label = source_metadata.get("label")
@@ -794,7 +780,6 @@ class WebUITranscriptRecorder:
cli_apps: list[dict[str, Any]] | None = None,
mcp_presets: list[dict[str, Any]] | None = None,
session_mentions: Sequence[Mapping[str, Any]] | None = None,
session_handles: Sequence[Mapping[str, Any]] | None = None,
) -> bool:
if text.strip() == "/stop" and not media_paths:
return False
@@ -805,7 +790,6 @@ class WebUITranscriptRecorder:
cli_apps=cli_apps,
mcp_presets=mcp_presets,
session_mentions=session_mentions,
session_handles=session_handles,
)
if payload is None:
return False
@@ -914,17 +898,36 @@ def write_session_messages_as_transcript(
messages: list[dict[str, Any]],
) -> None:
"""Write a minimal WebUI transcript from already-truncated session messages."""
target_chat_id = _chat_id_from_session_key(target_key)
rows: list[dict[str, Any]] = []
for msg in messages:
if is_hidden_history_message(msg):
continue
msg = public_history_message(msg)
role = msg.get("role")
content = msg.get("content")
text = content if isinstance(content, str) else ""
if role == "user":
row = _session_user_event(target_key, msg)
elif role == "assistant":
row = _session_assistant_event(target_key, msg)
row: dict[str, Any] = {"event": "user", "chat_id": target_chat_id, "text": text}
media = msg.get("media")
if isinstance(media, list) and media:
row["media_paths"] = [
str(p) for p in cast(list[Any], media) if isinstance(p, str) and p
]
for key in ("cli_apps", "mcp_presets", "session_mentions"):
value = msg.get(key)
if isinstance(value, list) and value:
row[key] = json.loads(json.dumps(value, ensure_ascii=False))
elif role == "assistant" and text.strip():
row = {"event": "message", "chat_id": target_chat_id, "text": text}
media = msg.get("media")
if isinstance(media, list) and media:
row["media"] = [
str(p) for p in cast(list[Any], media) if isinstance(p, str) and p
]
else:
continue
if row is not None:
rows.append(row)
rows.append(row)
_write_transcript_lines(target_key, rows)
@@ -960,55 +963,20 @@ def normalize_session_mentions_metadata(raw: object) -> list[dict[str, str]]:
name = item.get("name")
session_key = item.get("session_key")
title = item.get("title")
handle_id = item.get("id")
if not isinstance(name, str) or not isinstance(session_key, str):
continue
name = name.strip()[:80]
session_key = session_key.strip()[:512]
if not name or not session_key or _SESSION_MENTION_NAME_RE.fullmatch(name) is None:
continue
normalized.append({
mention = {
"name": name,
"session_key": session_key,
"title": title.strip()[:160] if isinstance(title, str) else "",
})
return normalized
def normalize_session_handles_metadata(raw: object) -> list[dict[str, Any]]:
"""Validate session-handle metadata crossing a persistence seam."""
if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes, bytearray)):
return []
normalized: list[dict[str, Any]] = []
for raw_item in cast(Sequence[object], raw)[:MAX_SESSION_MENTIONS]:
if not isinstance(raw_item, Mapping):
continue
item = cast(Mapping[str, object], raw_item)
name = item.get("name")
session_key = item.get("session_key")
handle_id = item.get("id")
if (
not isinstance(name, str)
or not isinstance(session_key, str)
or not isinstance(handle_id, str)
or _SESSION_HANDLE_ID_RE.fullmatch(handle_id) is None
):
continue
name = name.strip()[:80]
session_key = session_key.strip()[:512]
if not name or not session_key or _SESSION_MENTION_NAME_RE.fullmatch(name) is None:
continue
mention: dict[str, Any] = {
"id": handle_id,
"name": name,
"session_key": session_key,
}
color_slot = item.get("color_slot")
if (
isinstance(color_slot, int)
and not isinstance(color_slot, bool)
and 0 <= color_slot < 8
):
mention["color_slot"] = color_slot
if isinstance(handle_id, str) and _SESSION_HANDLE_ID_RE.fullmatch(handle_id):
mention["id"] = handle_id
normalized.append(mention)
return normalized
@@ -1019,11 +987,9 @@ def normalize_session_message_ui_metadata(raw: object) -> dict[str, Any] | None:
return None
raw_data = cast(Mapping[str, object], raw)
session = raw_data.get("session")
direction = raw_data.get("direction")
message_id = raw_data.get("message_id")
if (
direction not in {"incoming", "outgoing"}
or not isinstance(message_id, str)
not isinstance(message_id, str)
or not message_id.strip()
or not isinstance(session, Mapping)
):
@@ -1031,24 +997,18 @@ def normalize_session_message_ui_metadata(raw: object) -> dict[str, Any] | None:
session_data = cast(Mapping[str, object], session)
handle_id = session_data.get("id")
name = session_data.get("name")
color_slot = session_data.get("color_slot")
if (
not isinstance(handle_id, str)
or not handle_id.strip()
or _SESSION_HANDLE_ID_RE.fullmatch(handle_id) is None
or not isinstance(name, str)
or not name.strip()
or not isinstance(color_slot, int)
or isinstance(color_slot, bool)
or not 0 <= color_slot < 8
):
return None
handle: dict[str, Any] = {
"id": handle_id.strip()[:128],
"name": name.strip()[:80],
"color_slot": color_slot,
}
return {
"direction": direction,
"message_id": message_id.strip()[:128],
"session": handle,
}
@@ -1062,7 +1022,6 @@ def build_user_transcript_event(
cli_apps: list[Any] | None = None,
mcp_presets: list[Any] | None = None,
session_mentions: Sequence[Any] | None = None,
session_handles: Sequence[Any] | None = None,
) -> dict[str, Any] | None:
paths = [str(path) for path in (media_paths or []) if path]
if not text and not paths:
@@ -1091,9 +1050,6 @@ def build_user_transcript_event(
mentions = normalize_session_mentions_metadata(session_mentions)
if mentions:
event["session_mentions"] = mentions
handles = normalize_session_handles_metadata(session_handles)
if handles:
event["session_handles"] = handles
return event
@@ -1118,7 +1074,6 @@ def _session_user_event(
return None
if is_hidden_history_message(message):
return None
message_envelope = session_message_envelope(message)
message = public_history_message(message)
if _is_legacy_raw_subagent_result(message):
return None
@@ -1128,9 +1083,8 @@ def _session_user_event(
cli_apps = message.get("cli_apps")
mcp_presets = message.get("mcp_presets")
session_mentions = message.get("session_mentions")
session_handles = message.get("session_handles")
chat_id = session_key.split(":", 1)[1] if ":" in session_key else session_key
event = build_user_transcript_event(
return build_user_transcript_event(
chat_id,
text,
media_paths=cast(list[Any], media) if isinstance(media, list) else None,
@@ -1139,13 +1093,7 @@ def _session_user_event(
session_mentions=(
cast(list[Any], session_mentions) if isinstance(session_mentions, list) else None
),
session_handles=(
cast(list[Any], session_handles) if isinstance(session_handles, list) else None
),
)
if event is not None and message_envelope is not None:
event["session_message"] = session_message_public_metadata(message_envelope)
return event
def _assistant_text_signature(value: Any) -> str:
@@ -1331,9 +1279,7 @@ def _find_unique_session_turn(
def _user_recovery_signature(event: dict[str, Any]) -> str:
fields = {
key: event[key]
for key in (
"text", "media_paths", "cli_apps", "mcp_presets", "session_mentions", "session_handles"
)
for key in ("text", "media_paths", "cli_apps", "mcp_presets", "session_mentions")
if key in event
}
return json.dumps(fields, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
@@ -1790,9 +1736,7 @@ def replay_transcript_to_ui_messages(
return {}
source_data = cast(dict[str, Any], source)
kind = source_data.get("kind")
if not isinstance(kind, str) or (
not is_automation_kind(kind) and kind != "session"
):
if not isinstance(kind, str) or not is_automation_kind(kind):
return {}
out: dict[str, Any] = {"source": {"kind": kind}}
label = source_data.get("label")
@@ -2203,9 +2147,6 @@ def replay_transcript_to_ui_messages(
)
if session_mentions:
row["sessionMentions"] = session_mentions
session_handles = normalize_session_handles_metadata(rec.get("session_handles"))
if session_handles:
row["sessionHandles"] = session_handles
if session_message := normalize_session_message_ui_metadata(
rec.get("session_message")
):
+31 -58
View File
@@ -101,7 +101,6 @@ from nanobot.webui.session_context import session_context_payload
from nanobot.webui.session_list_index import (
WEBUI_SESSION_INDEX_INTERNAL_FIELDS,
indexed_workspace_scope,
is_persisted_webui_session_row,
list_webui_sessions,
)
from nanobot.webui.sidebar_state import (
@@ -729,57 +728,36 @@ class GatewayHTTPHandler:
def _sessions_list_payload(self) -> dict[str, Any]:
assert self.session_manager is not None
from nanobot.session.session_handles import (
SessionHandleDirectory,
SessionHandleSnapshot,
)
from nanobot.session.session_handles import session_handle_for_key
from nanobot.session.webui_turns import websocket_turn_wall_started_at
with self.session_manager.locked_session_files():
sessions = list_webui_sessions(self.session_manager)
cleaned: list[dict[str, Any]] = []
identity_snapshots: list[SessionHandleSnapshot] = []
stale_identity_keys: list[str] = []
default_scope: WorkspaceScope | None = None
for s in sessions:
key = s.get("key")
if not (isinstance(key, str) and key.startswith("websocket:")):
continue
row = {
k: v
for k, v in s.items()
if k != "path" and k not in WEBUI_SESSION_INDEX_INTERNAL_FIELDS
}
chat_id = key.split(":", 1)[1]
started_at = websocket_turn_wall_started_at(chat_id)
if started_at is not None:
row["run_started_at"] = started_at
if default_scope is None:
default_scope = self.workspaces.default_scope()
scope_present, raw_scope = indexed_workspace_scope(s)
scope = self.workspaces.scope_for_indexed_metadata(
raw_scope,
scope_present=scope_present,
default_scope=default_scope,
)
row["workspace_scope"] = scope.payload()
if is_persisted_webui_session_row(s):
identity_snapshots.append(SessionHandleSnapshot(
session_key=key,
workspace=scope.project_path,
))
else:
stale_identity_keys.append(key)
cleaned.append(row)
directory = SessionHandleDirectory(self.session_manager)
directory.remove_many(stale_identity_keys)
handles = directory.ensure_snapshot_many(identity_snapshots)
for row in cleaned:
key = cast(str, row["key"])
handle = handles.get(key)
if handle is not None:
row["handle"] = handle.public_payload()
sessions = list_webui_sessions(self.session_manager)
cleaned: list[dict[str, Any]] = []
default_scope: WorkspaceScope | None = None
for s in sessions:
key = s.get("key")
if not (isinstance(key, str) and key.startswith("websocket:")):
continue
row = {
k: v
for k, v in s.items()
if k != "path" and k not in WEBUI_SESSION_INDEX_INTERNAL_FIELDS
}
chat_id = key.split(":", 1)[1]
started_at = websocket_turn_wall_started_at(chat_id)
if started_at is not None:
row["run_started_at"] = started_at
if default_scope is None:
default_scope = self.workspaces.default_scope()
scope_present, raw_scope = indexed_workspace_scope(s)
scope = self.workspaces.scope_for_indexed_metadata(
raw_scope,
scope_present=scope_present,
default_scope=default_scope,
)
row["workspace_scope"] = scope.payload()
row["handle"] = session_handle_for_key(key).public_payload()
cleaned.append(row)
return {"sessions": cleaned}
def _handle_webui_thread_get(self, request: WsRequest, key: str) -> Response:
@@ -929,14 +907,9 @@ class GatewayHTTPHandler:
self.local_trigger_store.delete(job.id)
elif self.cron_service is not None:
self.cron_service.remove_job(job.id)
with self.session_manager.locked_session_files():
deleted = self.session_manager.delete_session(decoded_key)
transcript_deleted = delete_webui_thread(decoded_key)
if deleted or transcript_deleted:
from nanobot.session.session_handles import SessionHandleDirectory
SessionHandleDirectory(self.session_manager).remove_many([decoded_key])
return _http_json_response({"deleted": bool(deleted or transcript_deleted)})
session_deleted = self.session_manager.delete_session(decoded_key)
transcript_deleted = delete_webui_thread(decoded_key)
return _http_json_response({"deleted": bool(session_deleted or transcript_deleted)})
# -- Automation routes --------------------------------------------------