refactor(agent): unify request context routing

This commit is contained in:
chengyongru
2026-07-10 17:54:34 +08:00
committed by Xubin Ren
parent bb3b449e09
commit 42d7ad34a4
20 changed files with 363 additions and 423 deletions
+20 -58
View File
@@ -63,7 +63,7 @@ from nanobot.session.goal_state import (
sustained_goal_active, sustained_goal_active,
) )
from nanobot.session.history_visibility import HIDDEN_HISTORY_META from nanobot.session.history_visibility import HIDDEN_HISTORY_META
from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel from nanobot.session.keys import UNIFIED_SESSION_KEY
from nanobot.session.manager import ( from nanobot.session.manager import (
Session, Session,
SessionManager, SessionManager,
@@ -549,32 +549,6 @@ class AgentLoop:
"""Connect configured MCP servers.""" """Connect configured MCP servers."""
await agent_context.connect_mcp(self, self.tools) await agent_context.connect_mcp(self, self.tools)
def _set_tool_context(
self, channel: str, chat_id: str,
message_id: str | None = None, metadata: dict | None = None,
session_key: str | None = None,
) -> None:
"""Update context for all tools that need routing info."""
from nanobot.agent.tools.context import ContextAware
effective_key = session_key or session_key_for_channel(
channel,
chat_id,
unified_session=self._unified_session,
)
request_ctx = RequestContext(
channel=channel,
chat_id=chat_id,
message_id=message_id,
session_key=effective_key,
metadata=dict(metadata or {}),
)
for name in self.tools.tool_names:
tool = self.tools.get(name)
if tool and isinstance(tool, ContextAware):
tool.set_context(request_ctx)
@staticmethod @staticmethod
def _runtime_chat_id(msg: InboundMessage) -> str: def _runtime_chat_id(msg: InboundMessage) -> str:
"""Return the chat id shown in runtime metadata for the model.""" """Return the chat id shown in runtime metadata for the model."""
@@ -835,26 +809,6 @@ class AgentLoop:
session_metadata=session.metadata if session is not None else None, session_metadata=session.metadata if session is not None else None,
) )
effective_tools = tools or self.tools effective_tools = tools or self.tools
hook = build_agent_turn_hook(AgentTurnHookSpec(
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
channel=channel,
chat_id=chat_id,
message_id=message_id,
metadata=metadata,
session_key=active_session_key,
workspace=effective_scope.project_path,
tool_hint_max_length=self.tool_hint_max_length,
set_tool_context=self._set_tool_context,
on_iteration=lambda iteration: setattr(self, "_current_iteration", iteration),
registered_hook_factories=self._hook_factories,
turn_hook_factories=list(hook_factories or []),
registered_hooks=self._extra_hooks,
turn_hooks=list(hooks or []),
ephemeral=ephemeral,
run_extra_hooks_for_ephemeral=run_extra_hooks_for_ephemeral,
))
request_ctx = RequestContext( request_ctx = RequestContext(
channel=channel, channel=channel,
chat_id=chat_id, chat_id=chat_id,
@@ -880,6 +834,25 @@ class AgentLoop:
session_metadata = session.metadata if session is not None else None session_metadata = session.metadata if session is not None else None
try: try:
hook = build_agent_turn_hook(AgentTurnHookSpec(
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
channel=channel,
chat_id=chat_id,
message_id=message_id,
metadata=metadata,
session_key=active_session_key,
workspace=effective_scope.project_path,
tool_hint_max_length=self.tool_hint_max_length,
on_iteration=lambda iteration: setattr(self, "_current_iteration", iteration),
registered_hook_factories=self._hook_factories,
turn_hook_factories=list(hook_factories or []),
registered_hooks=self._extra_hooks,
turn_hooks=list(hooks or []),
ephemeral=ephemeral,
run_extra_hooks_for_ephemeral=run_extra_hooks_for_ephemeral,
))
result = await self.runner.run(AgentRunSpec( result = await self.runner.run(AgentRunSpec(
initial_messages=initial_messages, initial_messages=initial_messages,
tools=effective_tools, tools=effective_tools,
@@ -1251,10 +1224,6 @@ class AgentLoop:
if is_subagent and self._persist_subagent_followup(session, msg): if is_subagent and self._persist_subagent_followup(session, msg):
logger.debug("Subagent result persisted for session {}", key) logger.debug("Subagent result persisted for session {}", key)
self.sessions.save(session) self.sessions.save(session)
self._set_tool_context(
channel, chat_id, msg.metadata.get("message_id"),
msg.metadata, session_key=key,
)
current_role = "assistant" if is_subagent else "user" current_role = "assistant" if is_subagent else "user"
_hist_kwargs: dict[str, Any] = { _hist_kwargs: dict[str, Any] = {
"max_messages": self._max_messages, "max_messages": self._max_messages,
@@ -1535,13 +1504,6 @@ class AgentLoop:
ctx.session, ctx.session,
replay_max_messages=self._max_messages, replay_max_messages=self._max_messages,
) )
self._set_tool_context(
ctx.msg.channel,
ctx.msg.chat_id,
ctx.msg.metadata.get("message_id"),
ctx.msg.metadata,
session_key=ctx.session_key,
)
if message_tool := self.tools.get("message"): if message_tool := self.tools.get("message"):
if isinstance(message_tool, MessageTool): if isinstance(message_tool, MessageTool):
message_tool.start_turn() message_tool.start_turn()
-19
View File
@@ -28,26 +28,16 @@ class AgentProgressHook(AgentHook):
on_stream: Callable[[str], Awaitable[None]] | None = None, on_stream: Callable[[str], Awaitable[None]] | None = None,
on_stream_end: Callable[..., Awaitable[None]] | None = None, on_stream_end: Callable[..., Awaitable[None]] | None = None,
*, *,
channel: str = "cli",
chat_id: str = "direct",
message_id: str | None = None,
metadata: dict[str, Any] | None = None,
session_key: str | None = None, session_key: str | None = None,
tool_hint_max_length: int = 40, tool_hint_max_length: int = 40,
set_tool_context: Callable[..., None] | None = None,
on_iteration: Callable[[int], None] | None = None, on_iteration: Callable[[int], None] | None = None,
) -> None: ) -> None:
super().__init__(reraise=True) super().__init__(reraise=True)
self._on_progress = on_progress self._on_progress = on_progress
self._on_stream = on_stream self._on_stream = on_stream
self._on_stream_end = on_stream_end self._on_stream_end = on_stream_end
self._channel = channel
self._chat_id = chat_id
self._message_id = message_id
self._metadata = metadata or {}
self._session_key = session_key self._session_key = session_key
self._tool_hint_max_length = tool_hint_max_length self._tool_hint_max_length = tool_hint_max_length
self._set_tool_context = set_tool_context
self._on_iteration = on_iteration self._on_iteration = on_iteration
self._stream_buf = "" self._stream_buf = ""
self._think_extractor = IncrementalThinkExtractor() self._think_extractor = IncrementalThinkExtractor()
@@ -124,15 +114,6 @@ class AgentProgressHook(AgentHook):
for tc in context.tool_calls: for tc in context.tool_calls:
args_str = json.dumps(tc.arguments, ensure_ascii=False) args_str = json.dumps(tc.arguments, ensure_ascii=False)
logger.info("Tool call: {}({})", tc.name, args_str[:200]) logger.info("Tool call: {}({})", tc.name, args_str[:200])
if self._set_tool_context:
self._set_tool_context(
self._channel,
self._chat_id,
self._message_id,
self._metadata,
session_key=self._session_key,
)
async def emit_reasoning(self, reasoning_content: str | None) -> None: async def emit_reasoning(self, reasoning_content: str | None) -> None:
"""Publish a reasoning chunk; channel plugins decide whether to render.""" """Publish a reasoning chunk; channel plugins decide whether to render."""
if ( if (
+11
View File
@@ -1,6 +1,7 @@
"""Runtime context for tool construction.""" """Runtime context for tool construction."""
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar, Token from contextvars import ContextVar, Token
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Callable, Protocol, runtime_checkable from typing import Any, Callable, Protocol, runtime_checkable
@@ -36,6 +37,16 @@ def reset_request_context(token: Token[RequestContext | None]) -> None:
_CURRENT_REQUEST_CONTEXT.reset(token) _CURRENT_REQUEST_CONTEXT.reset(token)
@contextmanager
def request_context(ctx: RequestContext):
"""Bind one immutable request snapshot and restore the previous value."""
token = bind_request_context(ctx)
try:
yield ctx
finally:
reset_request_context(token)
def current_request_context() -> RequestContext | None: def current_request_context() -> RequestContext | None:
return _CURRENT_REQUEST_CONTEXT.get() return _CURRENT_REQUEST_CONTEXT.get()
+12 -19
View File
@@ -7,7 +7,7 @@ from datetime import datetime
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
IntegerSchema, IntegerSchema,
StringSchema, StringSchema,
@@ -51,19 +51,12 @@ _CRON_PARAMETERS = tool_parameters_schema(
@tool_parameters(_CRON_PARAMETERS) @tool_parameters(_CRON_PARAMETERS)
class CronTool(Tool, ContextAware): class CronTool(Tool):
"""Tool to schedule reminders and recurring tasks.""" """Tool to schedule reminders and recurring tasks."""
def __init__(self, cron_service: CronService, default_timezone: str = "UTC"): def __init__(self, cron_service: CronService, default_timezone: str = "UTC"):
self._cron = cron_service self._cron = cron_service
self._default_timezone = default_timezone self._default_timezone = default_timezone
self._session_key: ContextVar[str] = ContextVar("cron_session_key", default="")
self._origin_channel: ContextVar[str] = ContextVar("cron_origin_channel", default="")
self._origin_chat_id: ContextVar[str] = ContextVar("cron_origin_chat_id", default="")
self._origin_metadata: ContextVar[dict[str, Any] | None] = ContextVar(
"cron_origin_metadata",
default=None,
)
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False) self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
@classmethod @classmethod
@@ -74,15 +67,17 @@ class CronTool(Tool, ContextAware):
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: Any) -> Tool:
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone) return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone)
def set_context(self, ctx: RequestContext) -> None: @staticmethod
"""Set the current session context for scheduled cron job ownership.""" def _request_route() -> tuple[str, str, str, dict[str, Any]]:
"""Return routing from the authoritative request snapshot."""
ctx = current_request_context()
if ctx is None:
return "", "", "", {}
raw_key = f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else "" raw_key = f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else ""
self._session_key.set( session_key = (
raw_key if ctx.session_key == UNIFIED_SESSION_KEY else (ctx.session_key or "") raw_key if ctx.session_key == UNIFIED_SESSION_KEY else (ctx.session_key or "")
) )
self._origin_channel.set(ctx.channel or "") return session_key, ctx.channel or "", ctx.chat_id or "", dict(ctx.metadata or {})
self._origin_chat_id.set(ctx.chat_id or "")
self._origin_metadata.set(dict(ctx.metadata or {}))
def set_cron_context(self, active: bool): def set_cron_context(self, active: bool):
"""Mark whether the tool is executing inside a cron job callback.""" """Mark whether the tool is executing inside a cron job callback."""
@@ -171,11 +166,9 @@ class CronTool(Tool, ContextAware):
"describing what to do when the job triggers " "describing what to do when the job triggers "
"(e.g. the reminder text). Retry including message=\"...\"." "(e.g. the reminder text). Retry including message=\"...\"."
) )
session_key = self._session_key.get() session_key, origin_channel, origin_chat_id, origin_metadata = self._request_route()
if not session_key: if not session_key:
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session") return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
origin_channel = self._origin_channel.get()
origin_chat_id = self._origin_chat_id.get()
if not origin_channel or not origin_chat_id: if not origin_channel or not origin_chat_id:
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session") return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
if tz and not cron_expr: if tz and not cron_expr:
@@ -218,7 +211,7 @@ class CronTool(Tool, ContextAware):
session_key=session_key, session_key=session_key,
origin_channel=origin_channel, origin_channel=origin_channel,
origin_chat_id=origin_chat_id, origin_chat_id=origin_chat_id,
origin_metadata=dict(self._origin_metadata.get() or {}), origin_metadata=origin_metadata,
) )
return f"Created job '{job.name}' (id: {job.id})" return f"Created job '{job.name}' (id: {job.id})"
+4 -15
View File
@@ -16,12 +16,11 @@ There is **no** sub-agent orchestrator and **no** special WebSocket ``agent_ui``
from __future__ import annotations from __future__ import annotations
from contextvars import ContextVar
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
from nanobot.session.goal_state import ( from nanobot.session.goal_state import (
@@ -39,7 +38,7 @@ def _iso_now() -> str:
return datetime.now().isoformat() return datetime.now().isoformat()
class _GoalToolsMixin(ContextAware): class _GoalToolsMixin:
"""Shared routing context + Session lookup.""" """Shared routing context + Session lookup."""
def __init__( def __init__(
@@ -49,19 +48,9 @@ class _GoalToolsMixin(ContextAware):
) -> None: ) -> None:
self._sessions = sessions self._sessions = sessions
self._runtime_events = runtime_events self._runtime_events = runtime_events
# Each subclass gets its own ContextVar so concurrent tasks across
# different tool types (LongTaskTool vs CompleteGoalTool) do not
# interfere with each other.
self._request_ctx: ContextVar[RequestContext | None] = ContextVar(
f"{self.__class__.__name__}_request_ctx",
default=None,
)
def set_context(self, ctx: RequestContext) -> None:
self._request_ctx.set(ctx)
def _session(self): def _session(self):
request_ctx = self._request_ctx.get() request_ctx = current_request_context()
if request_ctx is None: if request_ctx is None:
return None return None
key = request_ctx.session_key key = request_ctx.session_key
@@ -72,7 +61,7 @@ class _GoalToolsMixin(ContextAware):
async def _publish_goal_state_changed(self, metadata: dict[str, Any]) -> None: async def _publish_goal_state_changed(self, metadata: dict[str, Any]) -> None:
"""Publish authoritative goal metadata as a runtime event.""" """Publish authoritative goal metadata as a runtime event."""
runtime_events = self._runtime_events runtime_events = self._runtime_events
rc = self._request_ctx.get() rc = current_request_context()
if runtime_events is None or rc is None: if runtime_events is None or rc is None:
return return
cid = (rc.chat_id or "").strip() cid = (rc.chat_id or "").strip()
+25 -27
View File
@@ -7,7 +7,7 @@ from typing import Any, Awaitable, Callable
from loguru import logger from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.path_utils import resolve_workspace_path from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@@ -45,7 +45,7 @@ from nanobot.security.workspace_access import current_tool_workspace
required=["content"], required=["content"],
) )
) )
class MessageTool(Tool, ContextAware): class MessageTool(Tool):
"""Tool to send messages to users on chat channels.""" """Tool to send messages to users on chat channels."""
def __init__( def __init__(
@@ -62,20 +62,10 @@ class MessageTool(Tool, ContextAware):
Path(workspace).expanduser() if workspace is not None else get_workspace_path() Path(workspace).expanduser() if workspace is not None else get_workspace_path()
) )
self._restrict_to_workspace = restrict_to_workspace self._restrict_to_workspace = restrict_to_workspace
self._default_channel: ContextVar[str] = ContextVar( self._fallback_channel = default_channel
"message_default_channel", default=default_channel self._fallback_chat_id = default_chat_id
) self._fallback_message_id = default_message_id
self._default_chat_id: ContextVar[str] = ContextVar( self._fallback_metadata: dict[str, Any] = {}
"message_default_chat_id", default=default_chat_id
)
self._default_message_id: ContextVar[str | None] = ContextVar(
"message_default_message_id",
default=default_message_id,
)
self._default_metadata: ContextVar[dict[str, Any]] = ContextVar(
"message_default_metadata",
default={},
)
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False) self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
self._turn_delivered_media_var: ContextVar[tuple[str, ...]] = ContextVar( self._turn_delivered_media_var: ContextVar[tuple[str, ...]] = ContextVar(
"message_turn_delivered_media", "message_turn_delivered_media",
@@ -99,13 +89,6 @@ class MessageTool(Tool, ContextAware):
restrict_to_workspace=ctx.config.restrict_to_workspace, restrict_to_workspace=ctx.config.restrict_to_workspace,
) )
def set_context(self, ctx: RequestContext) -> None:
"""Set the current message context."""
self._default_channel.set(ctx.channel)
self._default_chat_id.set(ctx.chat_id)
self._default_message_id.set(ctx.message_id)
self._default_metadata.set(dict(ctx.metadata or {}))
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
"""Set the callback for sending messages.""" """Set the callback for sending messages."""
self._send_callback = callback self._send_callback = callback
@@ -199,8 +182,23 @@ class MessageTool(Tool, ContextAware):
for row in buttons for row in buttons
): ):
return ToolResult.error("Error: buttons must be a list of list of strings") return ToolResult.error("Error: buttons must be a list of list of strings")
default_channel = self._default_channel.get() request_ctx = current_request_context()
default_chat_id = self._default_chat_id.get() default_channel = (
request_ctx.channel if request_ctx is not None else self._fallback_channel
)
default_chat_id = (
request_ctx.chat_id if request_ctx is not None else self._fallback_chat_id
)
default_message_id = (
request_ctx.message_id
if request_ctx is not None
else self._fallback_message_id
)
default_metadata = (
request_ctx.metadata
if request_ctx is not None
else self._fallback_metadata
)
channel = channel or default_channel channel = channel or default_channel
explicit_chat_id = chat_id explicit_chat_id = chat_id
if ( if (
@@ -224,7 +222,7 @@ class MessageTool(Tool, ContextAware):
# to the wrong chat entirely. # to the wrong chat entirely.
same_target = channel == default_channel and chat_id == default_chat_id same_target = channel == default_channel and chat_id == default_chat_id
if same_target: if same_target:
message_id = message_id or self._default_message_id.get() message_id = message_id or default_message_id
else: else:
message_id = None message_id = None
@@ -240,7 +238,7 @@ class MessageTool(Tool, ContextAware):
except (OSError, PermissionError, ValueError) as e: except (OSError, PermissionError, ValueError) as e:
return ToolResult.error(f"Error: media path is not allowed: {str(e)}") return ToolResult.error(f"Error: media path is not allowed: {str(e)}")
metadata = dict(self._default_metadata.get()) if same_target else {} metadata = dict(default_metadata) if same_target else {}
if message_id: if message_id:
metadata["message_id"] = message_id metadata["message_id"] = message_id
if self._record_channel_delivery_var.get() or media: if self._record_channel_delivery_var.get() or media:
+7
View File
@@ -4,6 +4,7 @@ import json
from typing import Any from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import ContextAware, current_request_context
def is_tool_error_result(name: str, result: Any) -> bool: def is_tool_error_result(name: str, result: Any) -> bool:
@@ -109,6 +110,12 @@ class ToolRegistry:
) )
) )
# Compatibility for external tools that still implement the legacy
# setter protocol. Built-ins read the authoritative ContextVar
# directly and never copy routing state.
if isinstance(tool, ContextAware) and (ctx := current_request_context()) is not None:
tool.set_context(ctx)
params = self._coerce_params(tool, params) params = self._coerce_params(tool, params)
if not isinstance(params, dict): if not isinstance(params, dict):
return tool, params, ( return tool, params, (
+8 -11
View File
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.runtime_state import RuntimeState from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.config_base import Base from nanobot.config_base import Base
@@ -41,7 +41,7 @@ def _is_subagent_status(value: Any) -> bool:
return isinstance(value, SubagentStatus) return isinstance(value, SubagentStatus)
class MyTool(Tool, ContextAware): class MyTool(Tool):
"""Check and set the agent loop's runtime configuration.""" """Check and set the agent loop's runtime configuration."""
_plugin_discoverable = False # Requires AgentLoop reference; registered manually _plugin_discoverable = False # Requires AgentLoop reference; registered manually
@@ -111,8 +111,6 @@ class MyTool(Tool, ContextAware):
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None: def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
self._runtime_state = runtime_state self._runtime_state = runtime_state
self._modify_allowed = modify_allowed self._modify_allowed = modify_allowed
self._channel = ""
self._chat_id = ""
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool: def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
cls = self.__class__ cls = self.__class__
@@ -120,14 +118,8 @@ class MyTool(Tool, ContextAware):
memo[id(self)] = result memo[id(self)] = result
result._runtime_state = self._runtime_state result._runtime_state = self._runtime_state
result._modify_allowed = self._modify_allowed result._modify_allowed = self._modify_allowed
result._channel = self._channel
result._chat_id = self._chat_id
return result return result
def set_context(self, ctx: RequestContext) -> None:
self._channel = ctx.channel
self._chat_id = ctx.chat_id
@property @property
def name(self) -> str: def name(self) -> str:
return "my" return "my"
@@ -184,7 +176,12 @@ class MyTool(Tool, ContextAware):
} }
def _audit(self, action: str, detail: str) -> None: def _audit(self, action: str, detail: str) -> None:
session = f"{self._channel}:{self._chat_id}" if self._channel else "unknown" ctx = current_request_context()
session = (
ctx.session_key or f"{ctx.channel}:{ctx.chat_id}"
if ctx is not None and ctx.channel
else "unknown"
)
logger.info("self.{} | {} | session:{}", action, detail, session) logger.info("self.{} | {} | session:{}", action, detail, session)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+14 -21
View File
@@ -2,11 +2,10 @@
from __future__ import annotations from __future__ import annotations
from contextvars import ContextVar
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_workspace_scope from nanobot.security.workspace_access import current_workspace_scope
@@ -30,30 +29,16 @@ if TYPE_CHECKING:
required=["task"], required=["task"],
) )
) )
class SpawnTool(Tool, ContextAware): class SpawnTool(Tool):
"""Tool to spawn a subagent for background task execution.""" """Tool to spawn a subagent for background task execution."""
def __init__(self, manager: "SubagentManager"): def __init__(self, manager: "SubagentManager"):
self._manager = manager self._manager = manager
self._origin_channel: ContextVar[str] = ContextVar("spawn_origin_channel", default="cli")
self._origin_chat_id: ContextVar[str] = ContextVar("spawn_origin_chat_id", default="direct")
self._session_key: ContextVar[str] = ContextVar("spawn_session_key", default="cli:direct")
self._origin_message_id: ContextVar[str | None] = ContextVar(
"spawn_origin_message_id",
default=None,
)
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: Any) -> Tool:
return cls(manager=ctx.subagent_manager) return cls(manager=ctx.subagent_manager)
def set_context(self, ctx: RequestContext) -> None:
"""Set the origin context for subagent announcements."""
self._origin_channel.set(ctx.channel)
self._origin_chat_id.set(ctx.chat_id)
self._session_key.set(ctx.session_key or f"{ctx.channel}:{ctx.chat_id}")
self._origin_message_id.set(ctx.message_id)
@property @property
def name(self) -> str: def name(self) -> str:
return "spawn" return "spawn"
@@ -84,13 +69,21 @@ class SpawnTool(Tool, ContextAware):
f"({running}/{limit} running). Wait for a running subagent " f"({running}/{limit} running). Wait for a running subagent "
f"to complete before spawning a new one." f"to complete before spawning a new one."
) )
request_ctx = current_request_context()
origin_channel = request_ctx.channel if request_ctx is not None else "cli"
origin_chat_id = request_ctx.chat_id if request_ctx is not None else "direct"
session_key = (
request_ctx.session_key or f"{origin_channel}:{origin_chat_id}"
if request_ctx is not None
else "cli:direct"
)
return await self._manager.spawn( return await self._manager.spawn(
task=task, task=task,
label=label, label=label,
origin_channel=self._origin_channel.get(), origin_channel=origin_channel,
origin_chat_id=self._origin_chat_id.get(), origin_chat_id=origin_chat_id,
session_key=self._session_key.get(), session_key=session_key,
origin_message_id=self._origin_message_id.get(), origin_message_id=request_ctx.message_id if request_ctx is not None else None,
temperature=temperature, temperature=temperature,
workspace_scope=current_workspace_scope(), workspace_scope=current_workspace_scope(),
) )
-6
View File
@@ -32,7 +32,6 @@ class AgentTurnHookSpec:
session_key: str | None = None session_key: str | None = None
workspace: Path | None = None workspace: Path | None = None
tool_hint_max_length: int = 40 tool_hint_max_length: int = 40
set_tool_context: Callable[..., None] | None = None
on_iteration: Callable[[int], None] | None = None on_iteration: Callable[[int], None] | None = None
registered_hook_factories: list[AgentTurnHookFactory] = field(default_factory=list) registered_hook_factories: list[AgentTurnHookFactory] = field(default_factory=list)
turn_hook_factories: list[AgentTurnHookFactory] = field(default_factory=list) turn_hook_factories: list[AgentTurnHookFactory] = field(default_factory=list)
@@ -48,13 +47,8 @@ def build_agent_turn_hook(spec: AgentTurnHookSpec) -> AgentHook:
on_progress=spec.on_progress, on_progress=spec.on_progress,
on_stream=spec.on_stream, on_stream=spec.on_stream,
on_stream_end=spec.on_stream_end, on_stream_end=spec.on_stream_end,
channel=spec.channel,
chat_id=spec.chat_id,
message_id=spec.message_id,
metadata=spec.metadata,
session_key=spec.session_key, session_key=spec.session_key,
tool_hint_max_length=spec.tool_hint_max_length, tool_hint_max_length=spec.tool_hint_max_length,
set_tool_context=spec.set_tool_context,
on_iteration=spec.on_iteration, on_iteration=spec.on_iteration,
) )
if spec.ephemeral and not spec.run_extra_hooks_for_ephemeral: if spec.ephemeral and not spec.run_extra_hooks_for_ephemeral:
+26 -17
View File
@@ -7,6 +7,7 @@ import pytest
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import ( from nanobot.bus.outbound_events import (
GoalStatusEvent, GoalStatusEvent,
@@ -1090,20 +1091,24 @@ async def test_process_direct_skip_user_persist_does_not_save_retry_user(
] ]
def test_set_tool_context_uses_effective_key_for_spawn_tool(tmp_path: Path) -> None: @pytest.mark.asyncio
async def test_request_context_uses_effective_key_for_spawn_tool(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path) loop = _make_full_loop(tmp_path)
spawn_tool = loop.tools.get("spawn") spawn_tool = loop.tools.get("spawn")
assert spawn_tool is not None assert spawn_tool is not None
spawn_tool._manager.spawn = AsyncMock(return_value="started") # type: ignore[attr-defined]
loop._set_tool_context( with request_context(RequestContext(
"discord", channel="discord",
"thread-777", chat_id="thread-777",
session_key="discord:parent-456:thread:thread-777", session_key="discord:parent-456:thread:thread-777",
) )):
await spawn_tool.execute(task="inspect context")
assert spawn_tool._origin_channel.get() == "discord" # type: ignore[attr-defined] call = spawn_tool._manager.spawn.await_args.kwargs # type: ignore[attr-defined]
assert spawn_tool._origin_chat_id.get() == "thread-777" # type: ignore[attr-defined] assert call["origin_channel"] == "discord"
assert spawn_tool._session_key.get() == "discord:parent-456:thread:thread-777" # type: ignore[attr-defined] assert call["origin_chat_id"] == "thread-777"
assert call["session_key"] == "discord:parent-456:thread:thread-777"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1422,21 +1427,25 @@ def test_subagent_followup_skips_empty_content() -> None:
assert session.messages == [] assert session.messages == []
def test_set_tool_context_passes_thread_session_key_to_spawn(tmp_path: Path) -> None: @pytest.mark.asyncio
async def test_request_context_passes_thread_session_key_to_spawn(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path) loop = _make_full_loop(tmp_path)
spawn_tool = loop.tools.get("spawn")
assert spawn_tool is not None
spawn_tool._manager.spawn = AsyncMock(return_value="started") # type: ignore[attr-defined]
loop._set_tool_context( with request_context(RequestContext(
"slack", channel="slack",
"C123", chat_id="C123",
message_id="msg-123", message_id="msg-123",
metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}}, metadata={"slack": {"thread_ts": "1700.42", "channel_type": "channel"}},
session_key="slack:C123:1700.42", session_key="slack:C123:1700.42",
) )):
await spawn_tool.execute(task="inspect thread")
spawn_tool = loop.tools.get("spawn") call = spawn_tool._manager.spawn.await_args.kwargs # type: ignore[attr-defined]
assert spawn_tool is not None assert call["session_key"] == "slack:C123:1700.42"
assert spawn_tool._session_key.get() == "slack:C123:1700.42" assert call["origin_message_id"] == "msg-123"
assert spawn_tool._origin_message_id.get() == "msg-123"
@pytest.mark.asyncio @pytest.mark.asyncio
+4 -4
View File
@@ -24,15 +24,15 @@ class _ContextRecordingTool:
def __init__(self) -> None: def __init__(self) -> None:
self.contexts: list[dict] = [] self.contexts: list[dict] = []
def set_context(self, ctx: RequestContext) -> None: async def execute(self, **_kwargs) -> str:
ctx = current_request_context()
assert ctx is not None
self.contexts.append({ self.contexts.append({
"channel": ctx.channel, "channel": ctx.channel,
"chat_id": ctx.chat_id, "chat_id": ctx.chat_id,
"metadata": ctx.metadata, "metadata": ctx.metadata,
"session_key": ctx.session_key, "session_key": ctx.session_key,
}) })
async def execute(self, **_kwargs) -> str:
return "created" return "created"
@@ -55,7 +55,7 @@ class _Tools:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_loop_hook_preserves_metadata_when_resetting_tool_context(tmp_path: Path) -> None: async def test_loop_binds_request_context_for_tool_execution(tmp_path: Path) -> None:
provider = MagicMock() provider = MagicMock()
calls = {"n": 0} calls = {"n": 0}
+34 -26
View File
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.long_task import ( from nanobot.agent.tools.long_task import (
CompleteGoalTool, CompleteGoalTool,
LongTaskTool, LongTaskTool,
@@ -24,15 +24,16 @@ from nanobot.session.webui_turns import WebuiTurnCoordinator
def _tools(sm: SessionManager) -> tuple[LongTaskTool, CompleteGoalTool]: def _tools(sm: SessionManager) -> tuple[LongTaskTool, CompleteGoalTool]:
lt = LongTaskTool(sessions=sm) lt = LongTaskTool(sessions=sm)
cg = CompleteGoalTool(sessions=sm) cg = CompleteGoalTool(sessions=sm)
rc = RequestContext( return lt, cg
def _request_context(chat_id: str = "c1") -> RequestContext:
return RequestContext(
channel="websocket", channel="websocket",
chat_id="c1", chat_id=chat_id,
session_key="websocket:c1", session_key=f"websocket:{chat_id}",
metadata={}, metadata={},
) )
lt.set_context(rc)
cg.set_context(rc)
return lt, cg
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -40,6 +41,7 @@ async def test_long_task_records_goal_metadata(tmp_path):
sm = SessionManager(tmp_path) sm = SessionManager(tmp_path)
lt, _cg = _tools(sm) lt, _cg = _tools(sm)
with request_context(_request_context()):
out = await lt.execute(goal="Do the thing", ui_summary="thing") out = await lt.execute(goal="Do the thing", ui_summary="thing")
assert "Goal recorded" in out assert "Goal recorded" in out
@@ -56,6 +58,7 @@ async def test_long_task_rejects_second_active_goal(tmp_path):
sm = SessionManager(tmp_path) sm = SessionManager(tmp_path)
lt, _cg = _tools(sm) lt, _cg = _tools(sm)
with request_context(_request_context()):
await lt.execute(goal="First") await lt.execute(goal="First")
out = await lt.execute(goal="Second") out = await lt.execute(goal="Second")
assert "already active" in out assert "already active" in out
@@ -66,6 +69,7 @@ async def test_complete_goal_closes_active_goal(tmp_path):
sm = SessionManager(tmp_path) sm = SessionManager(tmp_path)
lt, cg = _tools(sm) lt, cg = _tools(sm)
with request_context(_request_context()):
await lt.execute(goal="X") await lt.execute(goal="X")
out = await cg.execute(recap="Done.") out = await cg.execute(recap="Done.")
assert "marked complete" in out assert "marked complete" in out
@@ -84,19 +88,23 @@ async def test_goal_tools_keep_request_context_per_task(tmp_path):
ctx_a = RequestContext(channel="websocket", chat_id="a", session_key="websocket:a") ctx_a = RequestContext(channel="websocket", chat_id="a", session_key="websocket:a")
ctx_b = RequestContext(channel="websocket", chat_id="b", session_key="websocket:b") ctx_b = RequestContext(channel="websocket", chat_id="b", session_key="websocket:b")
lt.set_context(ctx_a) async def start_goal(ctx: RequestContext, goal: str) -> str:
task_a = asyncio.create_task(lt.execute(goal="Goal A")) with request_context(ctx):
lt.set_context(ctx_b) return await lt.execute(goal=goal)
task_b = asyncio.create_task(lt.execute(goal="Goal B"))
task_a = asyncio.create_task(start_goal(ctx_a, "Goal A"))
task_b = asyncio.create_task(start_goal(ctx_b, "Goal B"))
await asyncio.gather(task_a, task_b) await asyncio.gather(task_a, task_b)
assert sm.get_or_create("websocket:a").metadata[GOAL_STATE_KEY]["objective"] == "Goal A" assert sm.get_or_create("websocket:a").metadata[GOAL_STATE_KEY]["objective"] == "Goal A"
assert sm.get_or_create("websocket:b").metadata[GOAL_STATE_KEY]["objective"] == "Goal B" assert sm.get_or_create("websocket:b").metadata[GOAL_STATE_KEY]["objective"] == "Goal B"
cg.set_context(ctx_a) async def complete_goal(ctx: RequestContext, recap: str) -> str:
done_a = asyncio.create_task(cg.execute(recap="Done A")) with request_context(ctx):
cg.set_context(ctx_b) return await cg.execute(recap=recap)
done_b = asyncio.create_task(cg.execute(recap="Done B"))
done_a = asyncio.create_task(complete_goal(ctx_a, "Done A"))
done_b = asyncio.create_task(complete_goal(ctx_b, "Done B"))
await asyncio.gather(done_a, done_b) await asyncio.gather(done_a, done_b)
assert sm.get_or_create("websocket:a").metadata[GOAL_STATE_KEY]["recap"] == "Done A" assert sm.get_or_create("websocket:a").metadata[GOAL_STATE_KEY]["recap"] == "Done A"
@@ -104,19 +112,19 @@ async def test_goal_tools_keep_request_context_per_task(tmp_path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_goal_tools_context_isolated_across_tool_types(tmp_path): async def test_goal_tools_share_authoritative_request_context(tmp_path):
"""LongTaskTool and CompleteGoalTool must not share routing context.""" """Both goal tools resolve routing from the same request snapshot."""
sm = SessionManager(tmp_path) sm = SessionManager(tmp_path)
lt = LongTaskTool(sessions=sm) lt = LongTaskTool(sessions=sm)
cg = CompleteGoalTool(sessions=sm) cg = CompleteGoalTool(sessions=sm)
ctx = RequestContext(channel="websocket", chat_id="a", session_key="websocket:a") ctx = RequestContext(channel="websocket", chat_id="a", session_key="websocket:a")
lt.set_context(ctx) with request_context(ctx):
assert cg._request_ctx.get() is None assert lt._session() is sm.get_or_create("websocket:a")
assert cg._session() is sm.get_or_create("websocket:a")
cg.set_context(ctx) assert lt._session() is None
assert lt._request_ctx.get() is ctx assert cg._session() is None
assert cg._request_ctx.get() is ctx
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -137,8 +145,7 @@ async def test_long_task_publishes_goal_state_ws_after_save(tmp_path):
session_key="websocket:chat-99", session_key="websocket:chat-99",
metadata={}, metadata={},
) )
lt.set_context(rc) with request_context(rc):
await lt.execute(goal="Objective alpha", ui_summary="alpha") await lt.execute(goal="Objective alpha", ui_summary="alpha")
bus.publish_outbound.assert_awaited_once() bus.publish_outbound.assert_awaited_once()
@@ -172,11 +179,10 @@ async def test_complete_goal_publishes_inactive_goal_state_ws(tmp_path):
session_key="websocket:chat-z", session_key="websocket:chat-z",
metadata={}, metadata={},
) )
lt.set_context(rc) with request_context(rc):
await lt.execute(goal="X") await lt.execute(goal="X")
bus.publish_outbound.reset_mock() bus.publish_outbound.reset_mock()
cg.set_context(rc)
await cg.execute(recap="Done.") await cg.execute(recap="Done.")
bus.publish_outbound.assert_awaited_once() bus.publish_outbound.assert_awaited_once()
@@ -190,6 +196,7 @@ async def test_complete_goal_without_active_is_noop_message(tmp_path):
sm = SessionManager(tmp_path) sm = SessionManager(tmp_path)
_lt, cg = _tools(sm) _lt, cg = _tools(sm)
with request_context(_request_context()):
out = await cg.execute(recap="n/a") out = await cg.execute(recap="n/a")
assert "No active" in out assert "No active" in out
@@ -198,6 +205,7 @@ async def test_complete_goal_without_active_is_noop_message(tmp_path):
async def test_long_task_skips_ws_publish_without_bus(tmp_path): async def test_long_task_skips_ws_publish_without_bus(tmp_path):
sm = SessionManager(tmp_path) sm = SessionManager(tmp_path)
lt, _cg = _tools(sm) lt, _cg = _tools(sm)
with request_context(_request_context()):
out = await lt.execute(goal="Solo", ui_summary="s") out = await lt.execute(goal="Solo", ui_summary="s")
assert "Goal recorded" in out assert "Goal recorded" in out
+21 -8
View File
@@ -4,11 +4,12 @@ from __future__ import annotations
import time import time
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
import pytest import pytest
from pydantic import BaseModel from pydantic import BaseModel
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.self import MyTool from nanobot.agent.tools.self import MyTool
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1121,14 +1122,26 @@ class TestLastUsageInSummary:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# set_context (audit session tracking) # request context (audit session tracking)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSetContext: class TestRequestContext:
def test_set_context_stores_channel_and_chat_id(self): def test_audit_reads_bound_session(self):
from nanobot.agent.tools.context import RequestContext
tool = _make_tool() tool = _make_tool()
tool.set_context(RequestContext(channel="feishu", chat_id="oc_abc123")) ctx = RequestContext(
assert tool._channel == "feishu" channel="feishu",
assert tool._chat_id == "oc_abc123" chat_id="oc_abc123",
session_key="feishu:oc_abc123",
)
with patch("nanobot.agent.tools.self.logger.info") as info:
with request_context(ctx):
tool._audit("modify", "temperature = 0.2")
info.assert_called_once_with(
"self.{} | {} | session:{}",
"modify",
"temperature = 0.2",
"feishu:oc_abc123",
)
+2 -3
View File
@@ -159,11 +159,10 @@ async def test_spawn_tool_rejects_when_at_concurrency_limit(tmp_path):
mgr.runner.run = AsyncMock(side_effect=fake_run) mgr.runner.run = AsyncMock(side_effect=fake_run)
from nanobot.agent.tools.context import RequestContext from nanobot.agent.tools.context import RequestContext, request_context
tool = SpawnTool(mgr) tool = SpawnTool(mgr)
tool.set_context(RequestContext(channel="test", chat_id="c1", session_key="test:c1")) with request_context(RequestContext(channel="test", chat_id="c1", session_key="test:c1")):
# First spawn succeeds # First spawn succeeds
result = await tool.execute(task="first task") result = await tool.execute(task="first task")
assert "started" in result assert "started" in result
+20 -19
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
import pytest import pytest
from nanobot.agent.tools.context import RequestContext from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule
@@ -321,10 +321,9 @@ def test_remove_protected_dream_job_returns_clear_feedback(tmp_path) -> None:
def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None: def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai") tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
tool.set_context( with request_context(
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1") RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
) ):
result = tool._add_job(None, "Morning standup", None, "0 8 * * *", None, None) result = tool._add_job(None, "Morning standup", None, "0 8 * * *", None, None)
assert result.startswith("Created job") assert result.startswith("Created job")
@@ -334,12 +333,13 @@ def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None: def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai") tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
tool.set_context( with request_context(
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1") RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
):
result = tool._add_job(
None, "Morning reminder", None, None, None, "2026-03-25T08:00:00"
) )
result = tool._add_job(None, "Morning reminder", None, None, None, "2026-03-25T08:00:00")
assert result.startswith("Created job") assert result.startswith("Created job")
job = tool._cron.list_jobs()[0] job = tool._cron.list_jobs()[0]
expected = int(datetime(2026, 3, 25, 0, 0, 0, tzinfo=timezone.utc).timestamp() * 1000) expected = int(datetime(2026, 3, 25, 0, 0, 0, tzinfo=timezone.utc).timestamp() * 1000)
@@ -348,10 +348,9 @@ def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
def test_add_job_binds_current_session_key(tmp_path) -> None: def test_add_job_binds_current_session_key(tmp_path) -> None:
tool = _make_tool(tmp_path) tool = _make_tool(tmp_path)
tool.set_context( with request_context(
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1") RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
) ):
result = tool._add_job(None, "Morning standup", 60, None, None, None) result = tool._add_job(None, "Morning standup", 60, None, None, None)
assert result.startswith("Created job") assert result.startswith("Created job")
@@ -366,8 +365,7 @@ def test_add_job_binds_current_session_key(tmp_path) -> None:
def test_add_job_requires_session_key(tmp_path) -> None: def test_add_job_requires_session_key(tmp_path) -> None:
tool = _make_tool(tmp_path) tool = _make_tool(tmp_path)
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1")) with request_context(RequestContext(channel="telegram", chat_id="chat-1")):
result = tool._add_job(None, "Background refresh", 60, None, None, None) result = tool._add_job(None, "Background refresh", 60, None, None, None)
assert result == "Error: scheduled cron jobs must be created from a chat session" assert result == "Error: scheduled cron jobs must be created from a chat session"
@@ -403,10 +401,9 @@ def test_validate_params_requires_message_only_for_add(tmp_path) -> None:
def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None: def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None:
tool = _make_tool(tmp_path) tool = _make_tool(tmp_path)
tool.set_context( with request_context(
RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1") RequestContext(channel="telegram", chat_id="chat-1", session_key="telegram:chat-1")
) ):
result = tool._add_job(None, "", 60, None, None, None) result = tool._add_job(None, "", 60, None, None, None)
assert "action='add' requires a non-empty 'message'" in result assert "action='add' requires a non-empty 'message'" in result
@@ -417,10 +414,14 @@ def test_add_job_captures_owner_and_origin_without_legacy_delivery_fields(tmp_pa
"""CronTool stores owner/session identity separately from origin delivery context.""" """CronTool stores owner/session identity separately from origin delivery context."""
tool = _make_tool(tmp_path) tool = _make_tool(tmp_path)
meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}} meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
tool.set_context(RequestContext( with request_context(
channel="slack", chat_id="C99", metadata=meta, session_key="slack:C99:111.222" RequestContext(
)) channel="slack",
chat_id="C99",
metadata=meta,
session_key="slack:C99:111.222",
)
):
result = tool._add_job("test", "say hi", 60, None, None, None) result = tool._add_job("test", "say hi", 60, None, None, None)
assert "Created job" in result assert "Created job" in result
+8 -6
View File
@@ -9,9 +9,11 @@ and tightens the runtime error for ``add`` without ``message``.
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterator
import pytest import pytest
from nanobot.agent.tools.context import RequestContext from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
@@ -39,14 +41,14 @@ class _SvcStub:
@pytest.fixture @pytest.fixture
def registry() -> ToolRegistry: def registry() -> Iterator[ToolRegistry]:
tool = CronTool(_SvcStub(), default_timezone="UTC") tool = CronTool(_SvcStub(), default_timezone="UTC")
tool.set_context(
RequestContext(channel="channel", chat_id="chat-id", session_key="channel:chat-id")
)
reg = ToolRegistry() reg = ToolRegistry()
reg.register(tool) reg.register(tool)
return reg with request_context(
RequestContext(channel="channel", chat_id="chat-id", session_key="channel:chat-id")
):
yield reg
class TestSchemaContract: class TestSchemaContract:
+33 -44
View File
@@ -4,8 +4,7 @@ import asyncio
import pytest import pytest
from nanobot.agent.loop import AgentLoop from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.spawn import SpawnTool
@@ -26,14 +25,14 @@ async def test_message_tool_keeps_task_local_context() -> None:
tool = MessageTool(send_callback=send_callback) tool = MessageTool(send_callback=send_callback)
async def task_one() -> str: async def task_one() -> str:
tool.set_context(RequestContext(channel="feishu", chat_id="chat-a")) with request_context(RequestContext(channel="feishu", chat_id="chat-a")):
entered.set() entered.set()
await release.wait() await release.wait()
return await tool.execute(content="one") return await tool.execute(content="one")
async def task_two() -> str: async def task_two() -> str:
await entered.wait() await entered.wait()
tool.set_context(RequestContext(channel="email", chat_id="chat-b")) with request_context(RequestContext(channel="email", chat_id="chat-b")):
release.set() release.set()
return await tool.execute(content="two") return await tool.execute(content="two")
@@ -75,14 +74,14 @@ async def test_spawn_tool_keeps_task_local_context() -> None:
tool = SpawnTool(_Manager()) tool = SpawnTool(_Manager())
async def task_one() -> str: async def task_one() -> str:
tool.set_context(RequestContext(channel="whatsapp", chat_id="chat-a")) with request_context(RequestContext(channel="whatsapp", chat_id="chat-a")):
entered.set() entered.set()
await release.wait() await release.wait()
return await tool.execute(task="one") return await tool.execute(task="one")
async def task_two() -> str: async def task_two() -> str:
await entered.wait() await entered.wait()
tool.set_context(RequestContext(channel="telegram", chat_id="chat-b")) with request_context(RequestContext(channel="telegram", chat_id="chat-b")):
release.set() release.set()
return await tool.execute(task="two") return await tool.execute(task="two")
@@ -101,18 +100,18 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None:
release = asyncio.Event() release = asyncio.Event()
async def task_one() -> str: async def task_one() -> str:
tool.set_context( with request_context(
RequestContext(channel="feishu", chat_id="chat-a", session_key="feishu:chat-a") RequestContext(channel="feishu", chat_id="chat-a", session_key="feishu:chat-a")
) ):
entered.set() entered.set()
await release.wait() await release.wait()
return await tool.execute(action="add", message="first", every_seconds=60) return await tool.execute(action="add", message="first", every_seconds=60)
async def task_two() -> str: async def task_two() -> str:
await entered.wait() await entered.wait()
tool.set_context( with request_context(
RequestContext(channel="email", chat_id="chat-b", session_key="email:chat-b") RequestContext(channel="email", chat_id="chat-b", session_key="email:chat-b")
) ):
release.set() release.set()
return await tool.execute(action="add", message="second", every_seconds=60) return await tool.execute(action="add", message="second", every_seconds=60)
@@ -133,24 +132,25 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_message_tool_basic_set_context_and_execute() -> None: async def test_message_tool_basic_request_context_and_execute() -> None:
"""Single task: set_context then execute should route correctly.""" """A bound request context should route a single execution correctly."""
seen: list[tuple[str, str, str]] = [] seen: list[tuple[str, str, str]] = []
async def send_callback(msg): async def send_callback(msg):
seen.append((msg.channel, msg.chat_id, msg.content)) seen.append((msg.channel, msg.chat_id, msg.content))
tool = MessageTool(send_callback=send_callback) tool = MessageTool(send_callback=send_callback)
tool.set_context(RequestContext(channel="telegram", chat_id="chat-123", message_id="msg-456")) with request_context(
RequestContext(channel="telegram", chat_id="chat-123", message_id="msg-456")
):
result = await tool.execute(content="hello") result = await tool.execute(content="hello")
assert result == "Message sent to telegram:chat-123" assert result == "Message sent to telegram:chat-123"
assert seen == [("telegram", "chat-123", "hello")] assert seen == [("telegram", "chat-123", "hello")]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_message_tool_default_values_without_set_context() -> None: async def test_message_tool_default_values_without_request_context() -> None:
"""Without set_context, constructor defaults should be used.""" """Without a request context, constructor defaults should be used."""
seen: list[tuple[str, str, str]] = [] seen: list[tuple[str, str, str]] = []
async def send_callback(msg): async def send_callback(msg):
@@ -168,8 +168,8 @@ async def test_message_tool_default_values_without_set_context() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_spawn_tool_basic_set_context_and_execute() -> None: async def test_spawn_tool_basic_request_context_and_execute() -> None:
"""Single task: set_context then execute should pass correct origin.""" """A bound request context should provide the correct origin."""
seen: list[tuple[str, str, str]] = [] seen: list[tuple[str, str, str]] = []
class _Manager: class _Manager:
@@ -194,16 +194,15 @@ async def test_spawn_tool_basic_set_context_and_execute() -> None:
return f"ok: {task}" return f"ok: {task}"
tool = SpawnTool(_Manager()) tool = SpawnTool(_Manager())
tool.set_context(RequestContext(channel="feishu", chat_id="chat-abc")) with request_context(RequestContext(channel="feishu", chat_id="chat-abc")):
result = await tool.execute(task="do something") result = await tool.execute(task="do something")
assert result == "ok: do something" assert result == "ok: do something"
assert seen == [("feishu", "chat-abc", "feishu:chat-abc")] assert seen == [("feishu", "chat-abc", "feishu:chat-abc")]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_spawn_tool_default_values_without_set_context() -> None: async def test_spawn_tool_default_values_without_request_context() -> None:
"""Without set_context, default cli:direct should be used.""" """Without a request context, default cli:direct should be used."""
seen: list[tuple[str, str, str]] = [] seen: list[tuple[str, str, str]] = []
class _Manager: class _Manager:
@@ -234,13 +233,12 @@ async def test_spawn_tool_default_values_without_set_context() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cron_tool_basic_set_context_and_execute(tmp_path) -> None: async def test_cron_tool_basic_request_context_and_execute(tmp_path) -> None:
"""Single task: set_context then add job should use correct target.""" """A bound request context should provide the correct cron owner."""
tool = CronTool(CronService(tmp_path / "jobs.json")) tool = CronTool(CronService(tmp_path / "jobs.json"))
tool.set_context( with request_context(
RequestContext(channel="wechat", chat_id="user-789", session_key="wechat:user-789") RequestContext(channel="wechat", chat_id="user-789", session_key="wechat:user-789")
) ):
result = await tool.execute(action="add", message="standup", every_seconds=300) result = await tool.execute(action="add", message="standup", every_seconds=300)
assert result.startswith("Created job") assert result.startswith("Created job")
@@ -256,22 +254,14 @@ async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path
"""WebUI-created cron jobs stay attached to the creating chat.""" """WebUI-created cron jobs stay attached to the creating chat."""
tool = CronTool(CronService(tmp_path / "jobs.json")) tool = CronTool(CronService(tmp_path / "jobs.json"))
class _Tools: with request_context(
tool_names = ["cron"] RequestContext(
channel="websocket",
def get(self, name: str): chat_id="chat-123",
return tool if name == "cron" else None
loop = object.__new__(AgentLoop)
loop._unified_session = True
loop.tools = _Tools()
loop._set_tool_context(
"websocket",
"chat-123",
metadata={"webui": True}, metadata={"webui": True},
session_key=UNIFIED_SESSION_KEY, session_key=UNIFIED_SESSION_KEY,
) )
):
result = await tool.execute(action="add", message="standup", every_seconds=300) result = await tool.execute(action="add", message="standup", every_seconds=300)
assert result.startswith("Created job") assert result.startswith("Created job")
@@ -287,15 +277,14 @@ async def test_webui_cron_tool_uses_origin_session_when_unified_enabled(tmp_path
async def test_cron_tool_preserves_thread_scoped_session_key(tmp_path) -> None: async def test_cron_tool_preserves_thread_scoped_session_key(tmp_path) -> None:
"""Channel-provided thread session keys should remain the cron owner.""" """Channel-provided thread session keys should remain the cron owner."""
tool = CronTool(CronService(tmp_path / "jobs.json")) tool = CronTool(CronService(tmp_path / "jobs.json"))
tool.set_context( with request_context(
RequestContext( RequestContext(
channel="slack", channel="slack",
chat_id="C123", chat_id="C123",
metadata={"slack": {"thread_ts": "1700.42"}}, metadata={"slack": {"thread_ts": "1700.42"}},
session_key="slack:C123:1700.42", session_key="slack:C123:1700.42",
) )
) ):
result = await tool.execute(action="add", message="check thread", every_seconds=300) result = await tool.execute(action="add", message="check thread", every_seconds=300)
assert result.startswith("Created job") assert result.startswith("Created job")
@@ -309,7 +298,7 @@ async def test_cron_tool_preserves_thread_scoped_session_key(tmp_path) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cron_tool_no_context_returns_error(tmp_path) -> None: async def test_cron_tool_no_context_returns_error(tmp_path) -> None:
"""Without set_context, add should fail with a clear error.""" """Without a request context, add should fail with a clear error."""
tool = CronTool(CronService(tmp_path / "jobs.json")) tool = CronTool(CronService(tmp_path / "jobs.json"))
result = await tool.execute(action="add", message="test", every_seconds=60) result = await tool.execute(action="add", message="test", every_seconds=60)
+29 -36
View File
@@ -2,6 +2,7 @@ import os
import pytest import pytest
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.message import MessageTool
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.config.paths import get_workspace_path from nanobot.config.paths import get_workspace_path
@@ -108,10 +109,8 @@ async def test_message_tool_inherits_metadata_for_same_target() -> None:
tool = MessageTool(send_callback=_send) tool = MessageTool(send_callback=_send)
slack_meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}} slack_meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
from nanobot.agent.tools.context import RequestContext
tool.set_context(RequestContext(channel="slack", chat_id="C123", metadata=slack_meta))
with request_context(RequestContext(channel="slack", chat_id="C123", metadata=slack_meta)):
await tool.execute(content="thread reply") await tool.execute(content="thread reply")
assert sent[0].metadata == slack_meta assert sent[0].metadata == slack_meta
@@ -125,17 +124,22 @@ async def test_message_tool_clears_metadata_when_context_has_none() -> None:
sent.append(msg) sent.append(msg)
tool = MessageTool(send_callback=_send) tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext rich_context = RequestContext(
tool.set_context(
RequestContext(
channel="slack", channel="slack",
chat_id="C123", chat_id="C123",
metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}}, metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}},
),
) )
tool.set_context(RequestContext(channel="slack", chat_id="C123", metadata={})) with request_context(rich_context):
await tool.execute(content="thread reply")
sent.clear()
with request_context(
RequestContext(
channel="slack",
chat_id="C123",
metadata={},
),
):
await tool.execute(content="plain reply") await tool.execute(content="plain reply")
assert sent[0].metadata == {} assert sent[0].metadata == {}
@@ -149,16 +153,13 @@ async def test_message_tool_does_not_inherit_metadata_for_cross_target() -> None
sent.append(msg) sent.append(msg)
tool = MessageTool(send_callback=_send) tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext with request_context(
tool.set_context(
RequestContext( RequestContext(
channel="slack", channel="slack",
chat_id="C123", chat_id="C123",
metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}}, metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}},
), ),
) ):
await tool.execute(content="channel reply", channel="slack", chat_id="C999") await tool.execute(content="channel reply", channel="slack", chat_id="C999")
assert sent[0].metadata == {} assert sent[0].metadata == {}
@@ -337,14 +338,16 @@ async def test_message_tool_tracks_turn_media_for_same_target(tmp_path) -> None:
sent.append(msg) sent.append(msg)
tool = MessageTool(send_callback=_send) tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
tool.set_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={}))
tool.start_turn()
f = tmp_path / "doc.md" f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8") f.write_text("hello", encoding="utf-8")
await tool.execute(content="see file", channel="websocket", chat_id="chat-1", media=[str(f)]) with request_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})):
tool.start_turn()
await tool.execute(
content="see file",
channel="websocket",
chat_id="chat-1",
media=[str(f)],
)
assert tool.turn_delivered_media_paths() == [str(f.resolve())] assert tool.turn_delivered_media_paths() == [str(f.resolve())]
@@ -354,12 +357,10 @@ async def test_message_tool_start_turn_clears_tracked_media(tmp_path) -> None:
pass pass
tool = MessageTool(send_callback=_send) tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
tool.set_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={}))
tool.start_turn()
f = tmp_path / "doc.md" f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8") f.write_text("hello", encoding="utf-8")
with request_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})):
tool.start_turn()
await tool.execute(content="see file", media=[str(f)]) await tool.execute(content="see file", media=[str(f)])
tool.start_turn() tool.start_turn()
assert tool.turn_delivered_media_paths() == [] assert tool.turn_delivered_media_paths() == []
@@ -371,11 +372,9 @@ async def test_message_tool_cross_target_does_not_track_turn_media(tmp_path) ->
pass pass
tool = MessageTool(send_callback=_send) tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
tool.set_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={}))
f = tmp_path / "doc.md" f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8") f.write_text("hello", encoding="utf-8")
with request_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})):
await tool.execute( await tool.execute(
content="see file", content="see file",
channel="telegram", channel="telegram",
@@ -393,12 +392,10 @@ async def test_message_tool_rejects_wrong_explicit_ws_chat_id(tmp_path) -> None:
sent.append(msg) sent.append(msg)
tool = MessageTool(send_callback=_send) tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
conv = "550e8400-e29b-41d4-a716-446655440000" conv = "550e8400-e29b-41d4-a716-446655440000"
tool.set_context(RequestContext(channel="websocket", chat_id=conv, metadata={}))
f = tmp_path / "doc.md" f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8") f.write_text("hello", encoding="utf-8")
with request_context(RequestContext(channel="websocket", chat_id=conv, metadata={})):
result = await tool.execute( result = await tool.execute(
content="see file", content="see file",
channel="websocket", channel="websocket",
@@ -417,12 +414,10 @@ async def test_message_tool_allows_ws_explicit_when_matches_context(tmp_path) ->
sent.append(msg) sent.append(msg)
tool = MessageTool(send_callback=_send) tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
conv = "550e8400-e29b-41d4-a716-446655440000" conv = "550e8400-e29b-41d4-a716-446655440000"
tool.set_context(RequestContext(channel="websocket", chat_id=conv, metadata={}))
f = tmp_path / "doc.md" f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8") f.write_text("hello", encoding="utf-8")
with request_context(RequestContext(channel="websocket", chat_id=conv, metadata={})):
result = await tool.execute( result = await tool.execute(
content="see file", content="see file",
channel="websocket", channel="websocket",
@@ -442,12 +437,10 @@ async def test_message_tool_cli_context_may_target_other_ws_chat(tmp_path) -> No
sent.append(msg) sent.append(msg)
tool = MessageTool(send_callback=_send) tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
target = "550e8400-e29b-41d4-a716-446655440000" target = "550e8400-e29b-41d4-a716-446655440000"
tool.set_context(RequestContext(channel="cli", chat_id="direct", metadata={}))
f = tmp_path / "doc.md" f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8") f.write_text("hello", encoding="utf-8")
with request_context(RequestContext(channel="cli", chat_id="direct", metadata={})):
result = await tool.execute( result = await tool.execute(
content="ping", content="ping",
channel="websocket", channel="websocket",
+3 -2
View File
@@ -156,8 +156,9 @@ class TestMessageToolTurnTracking:
def test_sent_in_turn_tracks_same_target(self) -> None: def test_sent_in_turn_tracks_same_target(self) -> None:
tool = MessageTool() tool = MessageTool()
from nanobot.agent.tools.context import RequestContext from nanobot.agent.tools.context import RequestContext, request_context
tool.set_context(RequestContext(channel="feishu", chat_id="chat1"))
with request_context(RequestContext(channel="feishu", chat_id="chat1")):
assert not tool._sent_in_turn assert not tool._sent_in_turn
tool._sent_in_turn = True tool._sent_in_turn = True
assert tool._sent_in_turn assert tool._sent_in_turn