From f75d3519dbbe6dc25ec9f946e69936033bc83408 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sat, 11 Jul 2026 18:52:56 +0800 Subject: [PATCH] feat(agent): add persistent runtime context providers --- nanobot/agent/context.py | 133 +++-------------- nanobot/agent/loop.py | 126 +++++++++++----- nanobot/agent/subagent.py | 3 - nanobot/agent/tools/base.py | 5 + nanobot/agent/tools/cli_apps.py | 20 +++ nanobot/agent/tools/context.py | 4 + nanobot/agent/tools/loader.py | 3 + nanobot/agent/tools/long_task.py | 32 +++- nanobot/agent/tools/registry.py | 16 +- nanobot/apps/cli/utils.py | 5 +- nanobot/command/builtin.py | 2 +- nanobot/runtime_context.py | 126 ++++++++++++++++ nanobot/sdk/clients.py | 3 +- nanobot/sdk/types.py | 10 +- nanobot/session/manager.py | 23 ++- nanobot/session/webui_turns.py | 2 + nanobot/templates/agent/subagent_system.md | 2 - nanobot/webui/transcript.py | 3 + nanobot/webui/ws_http.py | 4 + tests/agent/test_context_builder.py | 147 +++---------------- tests/agent/test_context_prompt_cache.py | 29 ++-- tests/agent/test_loop_runner_integration.py | 115 ++++++++++++++- tests/agent/test_loop_save_turn.py | 117 ++++++++------- tests/agent/test_runtime_context.py | 118 +++++++++++++++ tests/agent/test_session_manager_history.py | 40 +++++ tests/channels/test_websocket_http_routes.py | 41 ++++++ tests/cli_apps/test_tool.py | 31 ++++ 27 files changed, 789 insertions(+), 371 deletions(-) create mode 100644 nanobot/runtime_context.py create mode 100644 tests/agent/test_runtime_context.py diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index ee87e4d8..1adab138 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -12,9 +12,14 @@ from nanobot.agent.tools import mcp as mcp_tools from nanobot.agent.tools.registry import ToolRegistry from nanobot.apps.cli import utils as cli_app_utils from nanobot.bus.events import InboundMessage -from nanobot.session.goal_state import goal_state_runtime_lines, sustained_goal_active +from nanobot.runtime_context import ( + RUNTIME_CONTEXT_END, + RUNTIME_CONTEXT_MESSAGE_META, + RUNTIME_CONTEXT_TAG, + RuntimeContextBlock, + append_runtime_context, +) from nanobot.utils.helpers import ( - current_time_str, detect_image_mime, load_bundled_template, truncate_text_to_tokens, @@ -27,19 +32,6 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]: return cli_app_utils.session_extra(metadata) | mcp_tools.session_extra(metadata) -def runtime_lines(state: Any, msg: Any, workspace: Path, *, skip: bool = False) -> list[str]: - """Return model-visible runtime annotations for turn-attached capabilities.""" - return [ - *cli_app_utils.runtime_lines(msg, workspace, skip=skip), - *mcp_tools.runtime_lines( - msg, - configured_server_names=set(state._mcp_servers), - connected_server_names=set(state._mcp_stacks), - skip=skip, - ), - ] - - async def connect_mcp(state: Any, tools: ToolRegistry) -> None: await mcp_tools.connect_missing_servers(state, tools) @@ -56,14 +48,10 @@ class ContextBuilder: """Builds the context (system prompt + messages) for the agent.""" BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"] - _GOAL_RUNTIME_GUIDANCE_TAG = "[Goal Runtime Guidance — host instructions]" - _GOAL_RUNTIME_GUIDANCE_END = "[/Goal Runtime Guidance]" - _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" - _HOST_TEXT_SUFFIX_META_KEY = "host_text_suffix" - _HOST_BLOCK_META_KEY = "nanobot_host_content" + _RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG _MAX_RECENT_HISTORY = 50 _MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens) - _RUNTIME_CONTEXT_END = "[/Runtime Context]" + _RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None): self.workspace = workspace @@ -139,45 +127,14 @@ class ContextBuilder: channel=channel or "", ) - @staticmethod - def _build_runtime_context( - channel: str | None, - chat_id: str | None, - timezone: str | None = None, - sender_id: str | None = None, - supplemental_lines: Sequence[str] | None = None, - ) -> str: - """Build untrusted runtime metadata block appended after user content.""" - lines = [f"Current Time: {current_time_str(timezone)}"] - if channel and chat_id: - lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] - if sender_id: - lines += [f"Sender ID: {sender_id}"] - if supplemental_lines: - lines.extend(supplemental_lines) - return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END - - @staticmethod - def _build_goal_runtime_guidance( - session_metadata: Mapping[str, Any] | None, - *, - goal_start_requested: bool, - ) -> str: - """Return turn-scoped goal guidance without changing the system prompt.""" - goal_active = sustained_goal_active(session_metadata) - if not goal_start_requested and not goal_active: - return "" - return render_template( - "agent/goal_runtime.md", - strip=True, - goal_start_requested=goal_start_requested, - goal_active=goal_active, - ) - @staticmethod def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]: if isinstance(left, str) and isinstance(right, str): - return f"{left}\n\n{right}" if left else right + if not left: + return right + if not right: + return left + return f"{left}\n\n{right}" def _to_blocks(value: Any) -> list[dict[str, Any]]: if isinstance(value, list): @@ -221,65 +178,17 @@ class ContextBuilder: sender_id: str | None = None, session_summary: str | None = None, session_metadata: Mapping[str, Any] | None = None, - current_runtime_lines: Sequence[str] | None = None, + runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None, workspace: Path | None = None, - runtime_state: Any | None = None, - inbound_message: Any | None = None, - skip_runtime_lines: bool = False, include_memory_recent_history: bool = True, session_key: str | None = None, unified_session: bool = False, - goal_start_requested: bool = False, ) -> list[dict[str, Any]]: """Build the complete message list for an LLM call.""" root = workspace or self.workspace - extra = [ - *goal_state_runtime_lines(session_metadata), - ] - if runtime_state is not None and inbound_message is not None: - extra.extend(runtime_lines(runtime_state, inbound_message, root, skip=skip_runtime_lines)) - if current_runtime_lines: - extra.extend(line for line in current_runtime_lines if line) - runtime_ctx = self._build_runtime_context( - channel, - chat_id, - self.timezone, - sender_id=sender_id, - supplemental_lines=extra or None, - ) user_content = self._build_user_content(current_message, media) - goal_guidance = ( - self._build_goal_runtime_guidance( - session_metadata, - goal_start_requested=goal_start_requested, - ) - if current_role == "user" - else "" - ) - - # Merge runtime guidance, context, and user content into a single user message - # to avoid consecutive same-role messages that some providers reject. - # Volatile content is appended to keep the user-content prefix stable for - # prompt-cache hits. Goal guidance precedes the metadata-only runtime block. - host_parts = [part for part in (goal_guidance, runtime_ctx) if part] - host_text_suffix = "\n\n".join(host_parts) - if isinstance(user_content, str): - merged = "\n\n".join( - part for part in (user_content, host_text_suffix) if part - ) - else: - merged = list(user_content) - if goal_guidance: - merged.append({ - "type": "text", - "text": goal_guidance, - "_meta": {self._HOST_BLOCK_META_KEY: True}, - }) - merged.append({ - "type": "text", - "text": runtime_ctx, - "_meta": {self._HOST_BLOCK_META_KEY: True}, - }) + blocks = list(runtime_context_blocks or ()) if current_role == "user" else [] + merged, runtime_context_meta = append_runtime_context(user_content, blocks) messages = [ { "role": "system", @@ -298,15 +207,15 @@ class ContextBuilder: if messages[-1].get("role") == current_role: last = dict(messages[-1]) last["content"] = self._merge_message_content(last.get("content"), merged) - if current_role == "user" and isinstance(user_content, str): + if current_role == "user" and runtime_context_meta is not None: internal_meta = dict(last.get("_meta") or {}) - internal_meta[self._HOST_TEXT_SUFFIX_META_KEY] = host_text_suffix + internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = runtime_context_meta last["_meta"] = internal_meta messages[-1] = last return messages current = {"role": current_role, "content": merged} - if current_role == "user" and isinstance(user_content, str): - current["_meta"] = {self._HOST_TEXT_SUFFIX_META_KEY: host_text_suffix} + if current_role == "user" and runtime_context_meta is not None: + current["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta} messages.append(current) return messages diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 5c9e5ca1..28871b14 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -12,6 +12,7 @@ from dataclasses import dataclass, field from enum import Enum, auto from functools import partial from pathlib import Path +from types import SimpleNamespace from typing import TYPE_CHECKING, Any, Awaitable, Callable from loguru import logger @@ -27,6 +28,7 @@ from nanobot.agent.memory import Consolidator from nanobot.agent.model_runtime import ModelRuntimeResolver from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec from nanobot.agent.subagent import SubagentManager +from nanobot.agent.tools import mcp as mcp_tools from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states from nanobot.agent.tools.message import MessageTool @@ -52,6 +54,15 @@ from nanobot.command import CommandContext, CommandRouter, register_builtin_comm from nanobot.config.schema import AgentDefaults, ModelPresetConfig from nanobot.providers.base import LLMProvider from nanobot.providers.factory import ProviderSnapshot +from nanobot.runtime_context import ( + RUNTIME_CONTEXT_HISTORY_META, + RUNTIME_CONTEXT_MESSAGE_META, + RuntimeContextBlock, + RuntimeContextProvider, + append_runtime_context, + resolve_runtime_context, + wrap_runtime_context_lines, +) from nanobot.security.workspace_access import ( WorkspaceScopeResolver, bind_workspace_scope, @@ -60,7 +71,6 @@ from nanobot.security.workspace_access import ( from nanobot.session import turn_continuation from nanobot.session.automation_turns import automation_history_overrides from nanobot.session.goal_state import ( - explicit_goal_requested, goal_state_runtime_lines, runner_wall_llm_timeout_s, sustained_goal_active, @@ -123,6 +133,8 @@ class TurnContext: history: list[dict[str, Any]] = field(default_factory=list) initial_messages: list[dict[str, Any]] = field(default_factory=list) + request_context: RequestContext | None = None + runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list) final_content: str | None = None tools_used: list[str] = field(default_factory=list) @@ -365,6 +377,8 @@ class AgentLoop: self._mcp_servers = mcp_servers or {} self._mcp_stacks: dict[str, MCPConnection] = {} self._mcp_connecting = False + self._runtime_context_providers: list[RuntimeContextProvider] = [] + self.register_runtime_context_provider(self._provide_mcp_runtime_context) self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks self._background_tasks: list[asyncio.Task] = [] self._session_locks: dict[str, asyncio.Lock] = {} @@ -554,6 +568,14 @@ class AgentLoop: """Connect configured MCP servers.""" await agent_context.connect_mcp(self, self.tools) + def register_runtime_context_provider( + self, + provider: RuntimeContextProvider, + ) -> None: + """Register a provider resolved once before each inbound model turn.""" + if provider not in self._runtime_context_providers: + self._runtime_context_providers.append(provider) + @staticmethod def _runtime_chat_id(msg: InboundMessage) -> str: """Return the chat id shown in runtime metadata for the model.""" @@ -608,6 +630,7 @@ class AgentLoop: self, msg: InboundMessage, session: Session, + runtime_context_blocks: list[RuntimeContextBlock] | None = None, **kwargs: Any, ) -> bool: """Persist the triggering user message before the turn starts. @@ -618,7 +641,7 @@ class AgentLoop: return False media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p] has_text = isinstance(msg.content, str) and msg.content.strip() - if has_text or media_paths: + if has_text or media_paths or runtime_context_blocks: extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata) extra.update(kwargs) text = msg.content if isinstance(msg.content, str) else "" @@ -626,6 +649,12 @@ class AgentLoop: if text_override is not None: text = text_override extra.update(automation_extra) + text, runtime_context_meta = append_runtime_context( + text, + runtime_context_blocks or (), + ) + if runtime_context_meta is not None: + extra[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta session.add_message("user", text, **extra) self._mark_pending_user_turn(session) self.sessions.save(session) @@ -639,7 +668,7 @@ class AgentLoop: history: list[dict[str, Any]], pending_summary: str | None, include_memory_recent_history: bool = True, - goal_start_requested: bool = False, + runtime_context_blocks: list[RuntimeContextBlock] | None = None, ) -> list[dict[str, Any]]: """Build the initial message list for the LLM turn.""" scope = self.workspace_scopes.for_message(msg, session.metadata) @@ -653,14 +682,53 @@ class AgentLoop: session_summary=pending_summary, session_metadata=session.metadata, workspace=scope.project_path, - runtime_state=self, - inbound_message=msg, - goal_start_requested=goal_start_requested, + runtime_context_blocks=runtime_context_blocks, include_memory_recent_history=include_memory_recent_history, session_key=session.key, unified_session=self._unified_session, ) + async def _provide_mcp_runtime_context( + self, + request: RequestContext, + ) -> RuntimeContextBlock | None: + lines = mcp_tools.runtime_lines( + SimpleNamespace(metadata=request.metadata), + configured_server_names=set(self._mcp_servers), + connected_server_names=set(self._mcp_stacks), + ) + content = wrap_runtime_context_lines(lines) + if not content: + return None + return RuntimeContextBlock(source="mcp", content=content) + + def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext: + scope = self.workspace_scopes.for_message(ctx.msg, ctx.session.metadata) + return RequestContext( + channel=ctx.msg.channel, + chat_id=ctx.msg.chat_id, + message_id=ctx.msg.metadata.get("message_id"), + session_key=ctx.session_key, + original_user_text=ctx.original_user_text, + runtime=ctx.runtime, + metadata=dict(ctx.msg.metadata or {}), + sender_id=ctx.msg.sender_id, + turn_id=ctx.turn_id, + workspace=scope.project_path, + ) + + async def _resolve_runtime_context_for_turn( + self, + ctx: TurnContext, + ) -> list[RuntimeContextBlock]: + tools = ctx.tools or self.tools + providers = [ + *tools.get_runtime_context_providers(), + *self._runtime_context_providers, + ] + assert ctx.request_context is not None + return await resolve_runtime_context(providers, ctx.request_context) + async def _dispatch_command_inline( self, msg: InboundMessage, @@ -731,6 +799,7 @@ class AgentLoop: hook_factories: list[AgentTurnHookFactory] | None = None, turn_scopes: list[AbstractContextManager[Any]] | None = None, tools: ToolRegistry | None = None, + request_context: RequestContext | None = None, ) -> tuple[str | None, list[str], list[dict], str, bool]: """Run the agent iteration loop. @@ -819,7 +888,7 @@ class AgentLoop: session_metadata=session.metadata if session is not None else None, ) effective_tools = tools or self.tools - request_ctx = RequestContext( + request_ctx = request_context or RequestContext( channel=channel, chat_id=chat_id, message_id=message_id, @@ -827,6 +896,7 @@ class AgentLoop: original_user_text=original_user_text, runtime=runtime, metadata=dict(metadata or {}), + workspace=effective_scope.project_path, ) file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key)) request_token = bind_request_context(request_ctx) @@ -1259,9 +1329,6 @@ class AgentLoop: session_summary=pending, session_metadata=session.metadata, workspace=workspace_scope.project_path, - runtime_state=self, - inbound_message=msg, - skip_runtime_lines=is_subagent, session_key=key, unified_session=self._unified_session, ) @@ -1561,16 +1628,20 @@ class AgentLoop: ctx.runtime, ) + ctx.request_context = self._request_context_for_turn(ctx) + ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx) ctx.initial_messages = self._build_initial_messages( ctx.msg, ctx.session, ctx.history, ctx.pending_summary, include_memory_recent_history=not ctx.ephemeral, - goal_start_requested=explicit_goal_requested(ctx.msg.metadata), + runtime_context_blocks=ctx.runtime_context_blocks, ) ctx.user_persisted_early = self._persist_user_message_early( - ctx.msg, ctx.session + ctx.msg, + ctx.session, + runtime_context_blocks=ctx.runtime_context_blocks, ) if ctx.on_progress is None: @@ -1610,6 +1681,7 @@ class AgentLoop: hook_factories=ctx.hook_factories, turn_scopes=ctx.turn_scopes, tools=ctx.tools, + request_context=ctx.request_context, ) final_content, tools_used, all_msgs, stop_reason, had_injections = result ctx.final_content = final_content @@ -1684,7 +1756,6 @@ class AgentLoop: content: list[dict[str, Any]], *, should_truncate_text: bool = False, - drop_runtime: bool = False, ) -> list[dict[str, Any]]: """Strip volatile multimodal payloads before writing session history.""" filtered: list[dict[str, Any]] = [] @@ -1693,14 +1764,6 @@ class AgentLoop: filtered.append(block) continue - if ( - drop_runtime - and block.get("type") == "text" - and isinstance(block.get("_meta"), dict) - and block["_meta"].get(ContextBuilder._HOST_BLOCK_META_KEY) is True - ): - continue - if block.get("type") == "image_url" and block.get("image_url", {}).get( "url", "" ).startswith("data:image/"): @@ -1741,8 +1804,8 @@ class AgentLoop: for m in messages[skip:]: entry = dict(m) internal_meta = entry.pop("_meta", None) - host_text_suffix = ( - internal_meta.get(ContextBuilder._HOST_TEXT_SUFFIX_META_KEY) + runtime_context_meta = ( + internal_meta.get(RUNTIME_CONTEXT_MESSAGE_META) if isinstance(internal_meta, dict) else None ) @@ -1770,24 +1833,13 @@ class AgentLoop: ] entry["content"] = filtered elif role == "user": - if ( - isinstance(content, str) - and isinstance(host_text_suffix, str) - and host_text_suffix - and content.endswith(host_text_suffix) - ): - before = content[: -len(host_text_suffix)] - if before.endswith("\n\n"): - before = before[:-2] - if before: - entry["content"] = before - else: - continue if isinstance(content, list): - filtered = self._sanitize_persisted_blocks(content, drop_runtime=True) + filtered = self._sanitize_persisted_blocks(content) if not filtered: continue entry["content"] = filtered + if isinstance(runtime_context_meta, dict): + entry[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta entry.setdefault("timestamp", datetime.now().isoformat()) session.messages.append(entry) if role == "assistant": diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 29f8588c..cf4a1ecd 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -433,10 +433,8 @@ class SubagentManager: def _build_subagent_prompt(self, workspace: Path | None = None) -> str: """Build a focused system prompt for the subagent.""" - from nanobot.agent.context import ContextBuilder from nanobot.agent.skills import SkillsLoader - time_ctx = ContextBuilder._build_runtime_context(None, None) root = workspace or self.workspace skills_summary = SkillsLoader( root, @@ -444,7 +442,6 @@ class SubagentManager: ).build_skills_summary() return render_template( "agent/subagent_system.md", - time_ctx=time_ctx, workspace=str(root), skills_summary=skills_summary or "", ) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 92e833f0..bcea7e5a 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -11,6 +11,7 @@ if typing.TYPE_CHECKING: from pydantic import BaseModel from nanobot.agent.tools.context import ToolContext + from nanobot.runtime_context import RuntimeContextProvider _ToolT = TypeVar("_ToolT", bound="Tool") @@ -206,6 +207,10 @@ class Tool(ABC): def create(cls, ctx: ToolContext) -> Tool: return cls() + def runtime_context_provider(self) -> RuntimeContextProvider | None: + """Return optional per-turn prompt context owned by this tool.""" + return None + @abstractmethod async def execute(self, **kwargs: Any) -> Any: """Run the tool; return content, or ``ToolResult.error(...)`` for failures.""" diff --git a/nanobot/agent/tools/cli_apps.py b/nanobot/agent/tools/cli_apps.py index 8a1430d3..7642e390 100644 --- a/nanobot/agent/tools/cli_apps.py +++ b/nanobot/agent/tools/cli_apps.py @@ -8,6 +8,7 @@ from typing import Any from pydantic import Field from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters +from nanobot.agent.tools.context import RequestContext from nanobot.agent.tools.schema import ( ArraySchema, BooleanSchema, @@ -16,7 +17,9 @@ from nanobot.agent.tools.schema import ( tool_parameters_schema, ) from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig +from nanobot.apps.cli.utils import runtime_lines_for_request from nanobot.config_base import Base +from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines from nanobot.security.workspace_access import current_tool_workspace @@ -112,6 +115,23 @@ class CliAppsTool(Tool): + installed_note ) + def runtime_context_provider(self): + return self._provide_runtime_context + + async def _provide_runtime_context( + self, + request: RequestContext, + ) -> RuntimeContextBlock | None: + lines = runtime_lines_for_request( + request.original_user_text or "", + request.metadata, + request.workspace or self.workspace, + ) + content = wrap_runtime_context_lines(lines) + if not content: + return None + return RuntimeContextBlock(source="cli_apps", content=content) + async def execute( self, name: str, diff --git a/nanobot/agent/tools/context.py b/nanobot/agent/tools/context.py index 7973f1d8..c64da5f3 100644 --- a/nanobot/agent/tools/context.py +++ b/nanobot/agent/tools/context.py @@ -4,6 +4,7 @@ from __future__ import annotations from contextlib import contextmanager from contextvars import ContextVar, Token from dataclasses import dataclass, field +from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable if TYPE_CHECKING: @@ -25,6 +26,9 @@ class RequestContext: original_user_text: str | None = None runtime: LLMRuntime | None = None metadata: dict[str, Any] = field(default_factory=dict) + sender_id: str | None = None + turn_id: str | None = None + workspace: Path | None = None @runtime_checkable diff --git a/nanobot/agent/tools/loader.py b/nanobot/agent/tools/loader.py index 555ccc46..fb420562 100644 --- a/nanobot/agent/tools/loader.py +++ b/nanobot/agent/tools/loader.py @@ -138,6 +138,9 @@ class _LegacyErrorPrefixTool(Tool): def parameters(self) -> dict[str, Any]: return self._wrapped.parameters + def runtime_context_provider(self): + return self._wrapped.runtime_context_provider() + @property def read_only(self) -> bool: return self._wrapped.read_only diff --git a/nanobot/agent/tools/long_task.py b/nanobot/agent/tools/long_task.py index a977d608..5aa36fc1 100644 --- a/nanobot/agent/tools/long_task.py +++ b/nanobot/agent/tools/long_task.py @@ -11,17 +11,22 @@ from nanobot.agent.goal_permission import ( revoke_goal_mutation_permission, ) from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters -from nanobot.agent.tools.context import current_request_context +from nanobot.agent.tools.context import RequestContext, current_request_context from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext +from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines from nanobot.session.goal_state import ( GOAL_STATE_KEY, MAX_GOAL_OBJECTIVE_CHARS, discard_legacy_goal_state_key, + explicit_goal_requested, goal_state_raw, + goal_state_runtime_lines, parse_goal_state, + sustained_goal_active, ) from nanobot.session.turn_continuation import reset_goal_continuation_rounds +from nanobot.utils.prompt_templates import render_template if TYPE_CHECKING: from nanobot.session.manager import SessionManager @@ -158,6 +163,31 @@ class CreateGoalTool(Tool, _GoalToolsMixin): "completion criteria. Do not retry after a successful creation." ) + 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 + session = self._sessions.get_or_create(request.session_key) + goal_start_requested = explicit_goal_requested(request.metadata) + goal_active = sustained_goal_active(session.metadata) + if not goal_start_requested and not goal_active: + return None + + guidance = render_template( + "agent/goal_runtime.md", + strip=True, + goal_start_requested=goal_start_requested, + goal_active=goal_active, + ) + state = wrap_runtime_context_lines(goal_state_runtime_lines(session.metadata)) + content = "\n\n".join(part for part in (guidance, state) if part) + return RuntimeContextBlock(source="goal", content=content) + async def execute( self, objective: str, diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index de3c019e..eb0b4f4f 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -1,11 +1,16 @@ """Tool registry for dynamic tool management.""" +from __future__ import annotations + import json -from typing import Any +from typing import TYPE_CHECKING, Any from nanobot.agent.tools.base import Tool, ToolResult from nanobot.agent.tools.context import ContextAware, current_request_context +if TYPE_CHECKING: + from nanobot.runtime_context import RuntimeContextProvider + def is_tool_error_result(name: str, result: Any) -> bool: return isinstance(result, ToolResult) and result.is_error @@ -36,6 +41,15 @@ class ToolRegistry: """Get a tool by name.""" return self._tools.get(name) + def get_runtime_context_providers(self) -> list[RuntimeContextProvider]: + """Return tool-owned providers in stable tool-name order.""" + providers: list[RuntimeContextProvider] = [] + for name in sorted(self._tools): + provider = self._tools[name].runtime_context_provider() + if provider is not None: + providers.append(provider) + return providers + @staticmethod def _lookup_key(name: str) -> str: """Normalize names for suggestions only; never for execution.""" diff --git a/nanobot/apps/cli/utils.py b/nanobot/apps/cli/utils.py index 8cfc7870..850dc598 100644 --- a/nanobot/apps/cli/utils.py +++ b/nanobot/apps/cli/utils.py @@ -18,14 +18,15 @@ def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[ return [] text = message.content if isinstance(getattr(message, "content", None), str) else "" metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None - return _cli_app_runtime_lines(text, metadata, workspace) + return runtime_lines_for_request(text, metadata, workspace) -def _cli_app_runtime_lines( +def runtime_lines_for_request( text: str, metadata: Mapping[str, Any] | None, workspace: Path, ) -> list[str]: + """Return CLI App annotations from an immutable request snapshot.""" structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None if isinstance(structured, list): mentions = [ diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index b618c151..3ce51765 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -747,7 +747,7 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage: ) session = ctx.session or ctx.loop.sessions.get_or_create(ctx.key) - history = session.get_history(max_messages=0) + history = session.get_history(max_messages=0, include_runtime_context=False) visible = [_format_history_message(m) for m in history] visible = [m for m in visible if m is not None] recent = visible[-count:] diff --git a/nanobot/runtime_context.py b/nanobot/runtime_context.py new file mode 100644 index 00000000..a6d13c25 --- /dev/null +++ b/nanobot/runtime_context.py @@ -0,0 +1,126 @@ +"""Optional, persistent context appended to the current user prompt.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence +from copy import deepcopy +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, TypeAlias + +if TYPE_CHECKING: + from nanobot.agent.tools.context import RequestContext + +RUNTIME_CONTEXT_HISTORY_META = "_runtime_context" +RUNTIME_CONTEXT_MESSAGE_META = "runtime_context" +RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" +RUNTIME_CONTEXT_END = "[/Runtime Context]" + + +@dataclass(frozen=True) +class RuntimeContextBlock: + """One provider-owned block appended to the current user content.""" + + source: str + content: str + + +RuntimeContextResult: TypeAlias = ( + RuntimeContextBlock | Sequence[RuntimeContextBlock] | None +) +RuntimeContextProvider: TypeAlias = Callable[ + ["RequestContext"], Awaitable[RuntimeContextResult] +] + + +def wrap_runtime_context_lines(lines: Iterable[str]) -> str: + """Wrap non-empty runtime metadata lines in the established prompt markers.""" + content = "\n".join(line for line in lines if line) + if not content: + return "" + return f"{RUNTIME_CONTEXT_TAG}\n{content}\n{RUNTIME_CONTEXT_END}" + + +def normalize_runtime_context_blocks(result: RuntimeContextResult) -> list[RuntimeContextBlock]: + """Return validated, non-empty blocks while preserving provider order.""" + if result is None: + return [] + values = [result] if isinstance(result, RuntimeContextBlock) else list(result) + blocks: list[RuntimeContextBlock] = [] + for block in values: + if not isinstance(block, RuntimeContextBlock): + raise TypeError("runtime context providers must return RuntimeContextBlock values") + source = block.source.strip() + content = block.content.strip() + if not source: + raise ValueError("runtime context block source must not be empty") + if content: + blocks.append(RuntimeContextBlock(source=source, content=content)) + return blocks + + +async def resolve_runtime_context( + providers: Iterable[RuntimeContextProvider], + request: RequestContext, +) -> list[RuntimeContextBlock]: + """Resolve providers once, sequentially, in the caller's stable order.""" + blocks: list[RuntimeContextBlock] = [] + for provider in providers: + blocks.extend(normalize_runtime_context_blocks(await provider(request))) + return blocks + + +def append_runtime_context( + content: Any, + blocks: Sequence[RuntimeContextBlock], +) -> tuple[Any, dict[str, Any] | None]: + """Append blocks and return a durable marker for exact display-time removal.""" + if not blocks: + return content, None + + rendered = [block.content for block in blocks] + sources = [block.source for block in blocks] + if isinstance(content, list): + context_blocks = [{"type": "text", "text": text} for text in rendered] + return [*content, *context_blocks], { + "version": 1, + "sources": sources, + "blocks": context_blocks, + } + + text = "" if content is None else str(content) + suffix = "\n\n".join(rendered) + merged = f"{text}\n\n{suffix}" if text else suffix + return merged, { + "version": 1, + "sources": sources, + "suffix": suffix, + } + + +def public_history_message(message: Mapping[str, Any]) -> dict[str, Any]: + """Return a user-visible copy with trusted runtime context removed exactly.""" + cleaned = deepcopy(dict(message)) + marker = cleaned.pop(RUNTIME_CONTEXT_HISTORY_META, None) + if not isinstance(marker, Mapping) or marker.get("version") != 1: + return cleaned + + content = cleaned.get("content") + suffix = marker.get("suffix") + if isinstance(content, str) and isinstance(suffix, str) and suffix: + if content == suffix: + cleaned["content"] = "" + elif content.endswith("\n\n" + suffix): + cleaned["content"] = content[: -(len(suffix) + 2)] + return cleaned + + expected = marker.get("blocks") + if isinstance(content, list) and isinstance(expected, list) and expected: + count = len(expected) + if content[-count:] == expected: + cleaned["content"] = content[:-count] + return cleaned + + +def public_history_messages(messages: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Return user-visible copies of persisted messages.""" + return [public_history_message(message) for message in messages] diff --git a/nanobot/sdk/clients.py b/nanobot/sdk/clients.py index 602aec76..4ff74771 100644 --- a/nanobot/sdk/clients.py +++ b/nanobot/sdk/clients.py @@ -7,6 +7,7 @@ from copy import deepcopy from pathlib import Path from typing import TYPE_CHECKING, Any +from nanobot.runtime_context import RUNTIME_CONTEXT_HISTORY_META from nanobot.sdk.types import ( SessionInfo, SessionSnapshot, @@ -22,7 +23,7 @@ if TYPE_CHECKING: class SessionClient: """Session management helpers exposed through ``bot.sessions``.""" - _RESERVED_MESSAGE_KEYS = {"role", "content"} + _RESERVED_MESSAGE_KEYS = {"role", "content", RUNTIME_CONTEXT_HISTORY_META} _VALID_ROLES = {"user", "assistant", "tool", "system"} def __init__(self, loop: AgentLoop) -> None: diff --git a/nanobot/sdk/types.py b/nanobot/sdk/types.py index 5e4963c3..0a2e5b34 100644 --- a/nanobot/sdk/types.py +++ b/nanobot/sdk/types.py @@ -6,6 +6,8 @@ from copy import deepcopy from dataclasses import dataclass, field from typing import Any, Literal, Mapping, TypeAlias +from nanobot.runtime_context import public_history_messages + StreamEventType: TypeAlias = Literal[ "run.started", "text.delta", @@ -125,7 +127,7 @@ def snapshot_from_session(session: Any) -> SessionSnapshot: created_at=session.created_at.isoformat(), updated_at=session.updated_at.isoformat(), metadata=deepcopy(session.metadata), - messages=deepcopy(session.messages), + messages=public_history_messages(session.messages), ) @@ -135,7 +137,11 @@ def snapshot_from_payload(payload: Mapping[str, Any]) -> SessionSnapshot: created_at=payload.get("created_at"), updated_at=payload.get("updated_at"), metadata=deepcopy(dict(payload.get("metadata") or {})), - messages=deepcopy(list(payload.get("messages") or [])), + messages=public_history_messages( + message + for message in list(payload.get("messages") or []) + if isinstance(message, Mapping) + ), ) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 533b5c9c..10f8ab3b 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -15,6 +15,10 @@ from typing import Any from loguru import logger from nanobot.config.paths import get_legacy_sessions_dir +from nanobot.runtime_context import ( + RUNTIME_CONTEXT_HISTORY_META, + public_history_message, +) from nanobot.utils.helpers import ( ensure_dir, estimate_message_tokens, @@ -93,6 +97,7 @@ def _text_preview(content: Any) -> str: def _message_preview_text(message: dict[str, Any]) -> str: """Session list preview text; subagent inject blobs are shortened for display.""" + message = public_history_message(message) content: Any = message.get("content") if message.get("injected_event") == "subagent_result" and isinstance(content, str): content = scrub_subagent_announce_body(content) @@ -153,6 +158,7 @@ class Session: *, max_tokens: int = 0, extend_to_user: bool = False, + include_runtime_context: bool = True, ) -> list[dict[str, Any]]: """Return unconsolidated messages for LLM input. @@ -187,6 +193,12 @@ class Session: for message in sliced: if message.get("_command"): continue + has_persisted_runtime_context = isinstance( + message.get(RUNTIME_CONTEXT_HISTORY_META), + dict, + ) + if not include_runtime_context: + message = public_history_message(message) content = message.get("content", "") role = message.get("role") if role == "assistant" and isinstance(content, str): @@ -203,7 +215,13 @@ class Session: ) content = f"{content}\n{breadcrumbs}" if content else breadcrumbs cli_apps = message.get("cli_apps") - if role == "user" and isinstance(cli_apps, list) and cli_apps and isinstance(content, str): + if ( + not has_persisted_runtime_context + and role == "user" + and isinstance(cli_apps, list) + and cli_apps + and isinstance(content, str) + ): cli_lines: list[str] = [] for item in cli_apps[:8]: if not isinstance(item, dict): @@ -221,7 +239,8 @@ class Session: content = f"{content}\n{breadcrumbs}" if content else breadcrumbs mcp_presets = message.get("mcp_presets") if ( - role == "user" + not has_persisted_runtime_context + and role == "user" and isinstance(mcp_presets, list) and mcp_presets and isinstance(content, str) diff --git a/nanobot/session/webui_turns.py b/nanobot/session/webui_turns.py index 3b7a4b63..1de44213 100644 --- a/nanobot/session/webui_turns.py +++ b/nanobot/session/webui_turns.py @@ -31,6 +31,7 @@ from nanobot.bus.runtime_events import ( TurnRunStatusChanged, ) from nanobot.providers.base import LLMProvider +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 @@ -79,6 +80,7 @@ def _title_inputs(session: Session) -> tuple[str, str]: continue if is_hidden_history_message(message): continue + message = public_history_message(message) role = message.get("role") content = message.get("content") if not isinstance(content, str) or not content.strip(): diff --git a/nanobot/templates/agent/subagent_system.md b/nanobot/templates/agent/subagent_system.md index 5d9d16c0..d7a3b25a 100644 --- a/nanobot/templates/agent/subagent_system.md +++ b/nanobot/templates/agent/subagent_system.md @@ -1,7 +1,5 @@ # Subagent -{{ time_ctx }} - You are a subagent spawned by the main agent to complete a specific task. Stay focused on the assigned task. Your final response will be reported back to the main agent. diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 3e713cb9..28ac7c9f 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -17,6 +17,7 @@ from urllib.parse import unquote, urlparse from loguru import logger from nanobot.config.paths import get_webui_dir +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 @@ -785,6 +786,7 @@ def write_session_messages_as_transcript( 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 "" @@ -876,6 +878,7 @@ def _session_user_event( return None if is_hidden_history_message(message): return None + message = public_history_message(message) if _is_legacy_raw_subagent_result(message): return None content = message.get("content") diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 9cec235f..b924e941 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -26,6 +26,7 @@ from websockets.http11 import Response from nanobot.command.builtin import builtin_command_palette from nanobot.cron.session_turns import is_bound_cron_job from nanobot.cron.types import CronJob, CronSchedule +from nanobot.runtime_context import public_history_messages from nanobot.triggers.local_types import LocalTrigger from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload @@ -426,6 +427,9 @@ class GatewayHTTPHandler: messages = data.get("messages") if isinstance(messages, list): scrub_subagent_messages_for_channel(messages) + data["messages"] = public_history_messages( + message for message in messages if isinstance(message, dict) + ) self.media.augment_media_urls(data) return _http_json_response(data) diff --git a/tests/agent/test_context_builder.py b/tests/agent/test_context_builder.py index dd51f3e1..5d58fc5e 100644 --- a/tests/agent/test_context_builder.py +++ b/tests/agent/test_context_builder.py @@ -5,8 +5,7 @@ from pathlib import Path import pytest from nanobot.agent.context import ContextBuilder -from nanobot.bus.events import InboundMessage -from nanobot.session.goal_state import GOAL_STATE_KEY +from nanobot.runtime_context import RuntimeContextBlock # --------------------------------------------------------------------------- # Helpers @@ -16,42 +15,6 @@ def _builder(tmp_path: Path, **kw) -> ContextBuilder: return ContextBuilder(workspace=tmp_path, **kw) -# --------------------------------------------------------------------------- -# _build_runtime_context (static) -# --------------------------------------------------------------------------- - - -class TestBuildRuntimeContext: - def test_time_only(self): - ctx = ContextBuilder._build_runtime_context(None, None) - assert "[Runtime Context" in ctx - assert "[/Runtime Context]" in ctx - assert "Current Time:" in ctx - assert "Channel:" not in ctx - - def test_with_channel_and_chat_id(self): - ctx = ContextBuilder._build_runtime_context("telegram", "chat123") - assert "Channel: telegram" in ctx - assert "Chat ID: chat123" in ctx - - def test_with_sender_id(self): - ctx = ContextBuilder._build_runtime_context("cli", "direct", sender_id="user1") - assert "Sender ID: user1" in ctx - - def test_with_timezone(self): - ctx = ContextBuilder._build_runtime_context(None, None, timezone="Asia/Shanghai") - assert "Current Time:" in ctx - - def test_no_channel_no_chat_id_omits_both(self): - ctx = ContextBuilder._build_runtime_context(None, None) - assert "Channel:" not in ctx - assert "Chat ID:" not in ctx - - def test_no_sender_id_omits(self): - ctx = ContextBuilder._build_runtime_context("cli", "direct") - assert "Sender ID:" not in ctx - - # --------------------------------------------------------------------------- # _merge_message_content (static) # --------------------------------------------------------------------------- @@ -315,118 +278,46 @@ class TestBuildMessages: assert messages[1]["role"] == "user" assert "hello" in str(messages[1]["content"]) - def test_runtime_context_injected(self, tmp_path): + def test_runtime_context_is_not_injected_by_default(self, tmp_path): builder = _builder(tmp_path) messages = builder.build_messages([], "hello", channel="cli", chat_id="direct") user_msg = str(messages[-1]["content"]) - assert "[Runtime Context" in user_msg - assert "hello" in user_msg + assert user_msg == "hello" + assert "Runtime Context" not in user_msg + assert "Current Time:" not in user_msg + assert "Chat ID:" not in user_msg - def test_session_metadata_injects_active_goal_state(self, tmp_path): + def test_session_metadata_does_not_inject_context_without_provider(self, tmp_path): builder = _builder(tmp_path) - meta = { - GOAL_STATE_KEY: {"status": "active", "objective": "Finish docs migration."}, - } messages = builder.build_messages( [], "hi", channel="cli", chat_id="x", - session_metadata=meta, + session_metadata={"goal_state": {"status": "active", "objective": "hidden"}}, ) - user_msg = str(messages[-1]["content"]) - assert ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG in user_msg - assert "Execute sustained work" in user_msg - assert "Start or replace the sustained goal" not in user_msg - assert "Goal (active):" in user_msg - assert "Finish docs migration." in user_msg + assert messages[-1]["content"] == "hi" - def test_goal_start_turn_injects_objective_guidance_after_user_text(self, tmp_path): - builder = _builder(tmp_path) - normal_messages = builder.build_messages([], "hi", channel="cli", chat_id="direct") - messages = builder.build_messages( - [], - "/goal audit the repo", - channel="cli", - chat_id="direct", - goal_start_requested=True, - ) - stale_messages = builder.build_messages( - [], - "/goal stale request", - channel="cli", - chat_id="direct", - inbound_message=InboundMessage( - channel="cli", - sender_id="system", - chat_id="direct", - content="/goal stale request", - metadata={"original_command": "/goal", "goal_requested": True}, - ), - ) - - user_msg = str(messages[-1]["content"]) - assert "Write a durable objective" in user_msg - assert "complete `/goal ` command" in user_msg - guidance = user_msg[ - user_msg.index(ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG) : - user_msg.index(ContextBuilder._GOAL_RUNTIME_GUIDANCE_END) - ].lower() - assert "authorization" not in guidance - assert "host-issued" not in guidance - assert user_msg.index("/goal audit the repo") < user_msg.index( - ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG - ) - assert user_msg.index(ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG) < user_msg.index( - ContextBuilder._RUNTIME_CONTEXT_TAG - ) - assert normal_messages[0]["content"] == messages[0]["content"] - assert ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG not in str( - normal_messages[-1]["content"] - ) - assert ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG not in str( - stale_messages[-1]["content"] - ) - - def test_goal_state_does_not_leak_without_session_metadata(self, tmp_path): - builder = _builder(tmp_path) - other_session_meta = { - GOAL_STATE_KEY: {"status": "active", "objective": "Other chat goal."}, - } - - with_goal = builder.build_messages( - [], - "hi", - channel="websocket", - chat_id="chat-a", - session_metadata=other_session_meta, - ) - without_goal = builder.build_messages( - [], - "hi", - channel="websocket", - chat_id="chat-b", - session_metadata={}, - ) - - assert "Other chat goal." in str(with_goal[-1]["content"]) - assert "Other chat goal." not in str(without_goal[-1]["content"]) - assert "Goal (active):" not in str(without_goal[-1]["content"]) - - def test_current_runtime_lines_are_injected(self, tmp_path): + def test_explicit_runtime_context_blocks_are_appended(self, tmp_path): builder = _builder(tmp_path) messages = builder.build_messages( [], "please use @zoom tonight", - current_runtime_lines=[ - "CLI App Attachment: @zoom (installed; tool=run_cli_app; entry_point=cli-anything-zoom).", + runtime_context_blocks=[ + RuntimeContextBlock( + source="cli_apps", + content="CLI App Attachment: @zoom (installed; tool=run_cli_app).", + ), ], ) user_msg = str(messages[-1]["content"]) assert "CLI App Attachment: @zoom" in user_msg assert "tool=run_cli_app" in user_msg - assert "entry_point=cli-anything-zoom" in user_msg + assert user_msg.index("please use @zoom tonight") < user_msg.index( + "CLI App Attachment: @zoom" + ) + assert messages[-1]["_meta"]["runtime_context"]["sources"] == ["cli_apps"] def test_consecutive_same_role_merged(self, tmp_path): builder = _builder(tmp_path) diff --git a/tests/agent/test_context_prompt_cache.py b/tests/agent/test_context_prompt_cache.py index baed100c..bbe75265 100644 --- a/tests/agent/test_context_prompt_cache.py +++ b/tests/agent/test_context_prompt_cache.py @@ -9,6 +9,7 @@ from importlib.resources import files as pkg_files from pathlib import Path from nanobot.agent.context import ContextBuilder +from nanobot.runtime_context import RuntimeContextBlock class _FakeDatetime(real_datetime): @@ -61,8 +62,7 @@ def test_system_prompt_reflects_current_dream_memory_contract(tmp_path) -> None: assert "write important facts here" not in prompt -def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: - """Runtime metadata should be merged with the user message.""" +def test_default_user_message_has_no_runtime_context(tmp_path) -> None: workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -76,19 +76,13 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert messages[0]["role"] == "system" assert "## Current Session" not in messages[0]["content"] - # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" user_content = messages[-1]["content"] - assert isinstance(user_content, str) - assert ContextBuilder._RUNTIME_CONTEXT_TAG in user_content - assert "Current Time:" in user_content - assert "Channel: cli" in user_content - assert "Chat ID: direct" in user_content - assert "Return exactly: OK" in user_content + assert user_content == "Return exactly: OK" + assert "_meta" not in messages[-1] -def test_runtime_context_appended_after_user_content(tmp_path) -> None: - """User content must precede runtime context for prompt-cache prefix stability.""" +def test_provider_context_appended_after_user_content(tmp_path) -> None: workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -97,16 +91,18 @@ def test_runtime_context_appended_after_user_content(tmp_path) -> None: current_message="hello world", channel="cli", chat_id="direct", + runtime_context_blocks=[ + RuntimeContextBlock(source="test", content="provider context"), + ], ) content = messages[-1]["content"] user_pos = content.find("hello world") - tag_pos = content.find(ContextBuilder._RUNTIME_CONTEXT_TAG) - assert user_pos < tag_pos, "user content must precede runtime context for prefix stability" + context_pos = content.find("provider context") + assert user_pos < context_pos, "user content must precede provider context" -def test_runtime_context_includes_sender_id_when_provided(tmp_path) -> None: - """Sender ID should be included in runtime context when provided.""" +def test_sender_id_is_not_injected_without_provider(tmp_path) -> None: workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -120,7 +116,8 @@ def test_runtime_context_includes_sender_id_when_provided(tmp_path) -> None: user_content = messages[-1]["content"] assert isinstance(user_content, str) - assert "Sender ID: user-12345" in user_content + assert user_content == "Return exactly: OK" + assert "Sender ID:" not in user_content def test_runtime_context_excludes_sender_id_when_not_provided(tmp_path) -> None: diff --git a/tests/agent/test_loop_runner_integration.py b/tests/agent/test_loop_runner_integration.py index 3a072868..cec77b7e 100644 --- a/tests/agent/test_loop_runner_integration.py +++ b/tests/agent/test_loop_runner_integration.py @@ -7,15 +7,16 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from nanobot.agent.context import ContextBuilder from nanobot.agent.goal_permission import goal_mutation_allowed, goal_mutation_permission from nanobot.bus.outbound_events import StreamedResponseEvent from nanobot.config.schema import AgentDefaults -from nanobot.providers.base import GenerationSettings, LLMResponse, ToolCallRequest +from nanobot.providers.base import GenerationSettings, LLMProvider, LLMResponse, ToolCallRequest +from nanobot.runtime_context import RuntimeContextBlock, public_history_message from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.utils.llm_runtime import LLMRuntime _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars +_GOAL_RUNTIME_GUIDANCE_TAG = "[Goal Runtime Guidance — host instructions]" def _make_loop(tmp_path): @@ -124,15 +125,117 @@ async def test_goal_command_can_implement_plan_from_prior_discussion(tmp_path): first_request = provider.chat_with_retry.await_args_list[0].kwargs["messages"] assert "staged migration plan" in str(first_request) assert "/goal implement the plan above" in str(first_request) - assert ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG in str(first_request) + assert _GOAL_RUNTIME_GUIDANCE_TAG in str(first_request) final_request = provider.chat_with_retry.await_args_list[-1].kwargs["messages"] assert "create_goal is unavailable for this turn" in str(final_request) - assert all( - ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG not in str(message.get("content") or "") - for message in session.messages + assert _GOAL_RUNTIME_GUIDANCE_TAG in str(session.messages[2]["content"]) + assert _GOAL_RUNTIME_GUIDANCE_TAG not in str( + public_history_message(session.messages[2])["content"] ) +@pytest.mark.asyncio +async def test_runtime_context_is_persisted_as_next_turn_prompt_prefix(tmp_path): + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + provider.generation = GenerationSettings() + provider.chat_with_retry = AsyncMock(side_effect=[ + LLMResponse(content="first answer", usage={}), + LLMResponse(content="second answer", usage={}), + ]) + loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model") + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None) + session = loop.sessions.get_or_create("cli:direct") + provider_calls: list[str | None] = [] + + async def provide_context(request): + provider_calls.append(request.turn_id) + return RuntimeContextBlock(source="test", content="stable provider context") + + loop.register_runtime_context_provider(provide_context) + loop.register_runtime_context_provider(provide_context) + + await loop._process_message(InboundMessage( + channel="cli", + sender_id="user", + chat_id="direct", + content="first turn", + )) + await loop._process_message(InboundMessage( + channel="cli", + sender_id="user", + chat_id="direct", + content="second turn", + )) + + first_request = provider.chat_with_retry.await_args_list[0].kwargs["messages"] + second_request = provider.chat_with_retry.await_args_list[1].kwargs["messages"] + first_wire = LLMProvider._sanitize_empty_content(first_request) + second_wire = LLMProvider._sanitize_empty_content(second_request) + assert second_wire[: len(first_wire)] == first_wire + assert first_wire[1] == second_wire[1] + assert second_wire[2]["role"] == "assistant" + assert second_wire[2]["content"] == "first answer" + assert second_wire[3]["content"].startswith("second turn") + assert "Current Time:" not in str(second_wire) + assert "Chat ID:" not in str(second_wire) + assert len(provider_calls) == 2 + + persisted_first_user = session.messages[0] + assert persisted_first_user["content"] == first_wire[1]["content"] + assert public_history_message(persisted_first_user)["content"] == "first turn" + + +@pytest.mark.asyncio +async def test_runtime_context_provider_runs_once_across_tool_iterations(tmp_path): + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + + (tmp_path / "note.txt").write_text("hello", encoding="utf-8") + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + provider.generation = GenerationSettings() + provider.chat_with_retry = AsyncMock(side_effect=[ + LLMResponse( + content="reading", + tool_calls=[ToolCallRequest( + id="call_read", + name="read_file", + arguments={"path": "note.txt"}, + )], + usage={}, + ), + LLMResponse(content="done", usage={}), + ]) + loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model") + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None) + provider_calls = 0 + + async def provide_context(_request): + nonlocal provider_calls + provider_calls += 1 + return RuntimeContextBlock(source="test", content="frozen context") + + loop.register_runtime_context_provider(provide_context) + + await loop._process_message(InboundMessage( + channel="cli", + sender_id="user", + chat_id="direct", + content="read the note", + )) + + assert provider.chat_with_retry.await_count == 2 + assert provider_calls == 1 + for call in provider.chat_with_retry.await_args_list: + assert "frozen context" in str(call.kwargs["messages"]) + + @pytest.mark.asyncio async def test_non_goal_direct_turn_cannot_reuse_prior_goal_command(tmp_path): from nanobot.agent.loop import AgentLoop diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index c4da4fbe..e6866f06 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -18,8 +18,15 @@ from nanobot.bus.outbound_events import ( ) from nanobot.bus.queue import MessageBus from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META -from nanobot.providers.base import LLMProvider, LLMResponse +from nanobot.providers.base import LLMResponse from nanobot.providers.factory import ProviderSnapshot +from nanobot.runtime_context import ( + RUNTIME_CONTEXT_HISTORY_META, + RUNTIME_CONTEXT_MESSAGE_META, + RuntimeContextBlock, + append_runtime_context, + public_history_message, +) from nanobot.session.automation_turns import AUTOMATION_HISTORY_META from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.manager import Session, SessionManager @@ -48,19 +55,13 @@ def _mk_loop() -> AgentLoop: return loop -def _host_text_message(content: str, suffix: str) -> dict: +def _runtime_message(content, blocks: list[RuntimeContextBlock]) -> dict: + merged, marker = append_runtime_context(content, blocks) + assert marker is not None return { "role": "user", - "content": content, - "_meta": {ContextBuilder._HOST_TEXT_SUFFIX_META_KEY: suffix}, - } - - -def _host_text_block(text: str) -> dict: - return { - "type": "text", - "text": text, - "_meta": {ContextBuilder._HOST_BLOCK_META_KEY: True}, + "content": merged, + "_meta": {RUNTIME_CONTEXT_MESSAGE_META: marker}, } @@ -357,74 +358,80 @@ def test_webui_title_update_uses_captured_llm_runtime( assert captured["model"] == "turn-model" -def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None: +def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None: loop = _mk_loop() session = Session(key="test:runtime-only") - runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)" + block = RuntimeContextBlock(source="test", content="provider context") loop._save_turn( session, - [{"role": "user", "content": [_host_text_block(runtime)]}], + [_runtime_message([], [block])], skip=0, ) - assert session.messages == [] + assert session.messages[0]["content"] == [ + {"type": "text", "text": "provider context"} + ] + assert public_history_message(session.messages[0])["content"] == [] -def test_save_turn_keeps_image_placeholder_with_path_after_runtime_strip() -> None: +def test_save_turn_keeps_image_placeholder_and_runtime_context() -> None: loop = _mk_loop() session = Session(key="test:image") - runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)" + block = RuntimeContextBlock(source="test", content="provider context") loop._save_turn( session, - [{ - "role": "user", - "content": [ + [_runtime_message( + [ {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}, "_meta": {"path": "/media/feishu/photo.jpg"}}, - _host_text_block(runtime), ], - }], + [block], + )], skip=0, ) - assert session.messages[0]["content"] == [{"type": "text", "text": "[image: /media/feishu/photo.jpg]"}] + assert session.messages[0]["content"] == [ + {"type": "text", "text": "[image: /media/feishu/photo.jpg]"}, + {"type": "text", "text": "provider context"}, + ] + assert public_history_message(session.messages[0])["content"] == [ + {"type": "text", "text": "[image: /media/feishu/photo.jpg]"} + ] def test_save_turn_keeps_image_placeholder_without_meta() -> None: loop = _mk_loop() session = Session(key="test:image-no-meta") - runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)" + block = RuntimeContextBlock(source="test", content="provider context") loop._save_turn( session, - [{ - "role": "user", - "content": [ + [_runtime_message( + [ {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, - _host_text_block(runtime), ], - }], + [block], + )], skip=0, ) - assert session.messages[0]["content"] == [{"type": "text", "text": "[image]"}] + assert session.messages[0]["content"] == [ + {"type": "text", "text": "[image]"}, + {"type": "text", "text": "provider context"}, + ] -def test_save_turn_strips_host_guidance_suffix_from_string() -> None: +def test_save_turn_persists_runtime_context_and_public_view_hides_it() -> None: loop = _mk_loop() session = Session(key="test:suffix-strip") - guidance = ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG + "\ninternal guidance" - runtime = ( - ContextBuilder._RUNTIME_CONTEXT_TAG - + "\nCurrent Time: now\n" - + ContextBuilder._RUNTIME_CONTEXT_END - ) - suffix = f"{guidance}\n\n{runtime}" + block = RuntimeContextBlock(source="goal", content="internal goal guidance") loop._save_turn( session, - [_host_text_message(f"hello world\n\n{suffix}", suffix)], + [_runtime_message("hello world", [block])], skip=0, ) - assert session.messages[0]["content"] == "hello world" + assert session.messages[0]["content"] == "hello world\n\ninternal goal guidance" + assert session.messages[0][RUNTIME_CONTEXT_HISTORY_META]["sources"] == ["goal"] + assert public_history_message(session.messages[0])["content"] == "hello world" def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_path: Path) -> None: @@ -432,7 +439,7 @@ def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_pat session = Session(key="test:user-guidance-literal") user_text = ( "Keep this prefix\n" - f"{ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG}\n" + "[Goal Runtime Guidance — host instructions]\n" "This label and everything after it are user-authored." ) messages = ContextBuilder(tmp_path).build_messages( @@ -440,10 +447,8 @@ def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_pat user_text, channel="cli", chat_id="direct", - goal_start_requested=True, ) - assert "_meta" in messages[-1] - assert "_meta" not in LLMProvider._sanitize_empty_content(messages)[-1] + assert "_meta" not in messages[-1] loop._save_turn(session, messages, skip=1) @@ -467,7 +472,6 @@ def test_build_and_save_preserves_multimodal_user_block_starting_with_runtime_ta media=[str(image)], channel="cli", chat_id="direct", - goal_start_requested=True, ) loop._save_turn(session, messages, skip=1) @@ -475,21 +479,18 @@ def test_build_and_save_preserves_multimodal_user_block_starting_with_runtime_ta assert {"type": "text", "text": user_text} in session.messages[0]["content"] -def test_save_turn_skips_string_user_when_only_runtime_context_suffix() -> None: +def test_save_turn_keeps_string_when_only_runtime_context() -> None: loop = _mk_loop() session = Session(key="test:suffix-only") - runtime = ( - ContextBuilder._RUNTIME_CONTEXT_TAG - + "\nCurrent Time: now\n" - + ContextBuilder._RUNTIME_CONTEXT_END - ) + block = RuntimeContextBlock(source="test", content="provider context") loop._save_turn( session, - [_host_text_message(runtime, runtime)], + [_runtime_message("", [block])], skip=0, ) - assert session.messages == [] + assert session.messages[0]["content"] == "provider context" + assert public_history_message(session.messages[0])["content"] == "" def test_save_turn_keeps_tool_results_under_16k() -> None: @@ -847,9 +848,10 @@ async def test_internal_continuation_queues_turn_without_fake_user_history( assert "Finish the long goal." in queued.content session = loop.sessions.get_or_create("feishu:c-auto") + assert "Finish the long goal." in str(session.messages[0]["content"]) assert [ {k: v for k, v in m.items() if k in {"role", "content"}} - for m in session.messages + for m in map(public_history_message, session.messages) ] == [{"role": "user", "content": "start the goal"}] second = await loop._process_message(queued, pending_queue=asyncio.Queue()) @@ -859,7 +861,7 @@ async def test_internal_continuation_queues_turn_without_fake_user_history( session = loop.sessions.get_or_create("feishu:c-auto") assert [ {k: v for k, v in m.items() if k in {"role", "content"}} - for m in session.messages + for m in map(public_history_message, session.messages) ] == [ {"role": "user", "content": "start the goal"}, {"role": "assistant", "content": "done"}, @@ -1399,7 +1401,8 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_ assert "[Message Time:" not in non_system[0]["content"] assert "[Message Time:" not in non_system[1]["content"] assert non_system[2]["content"].count("subagent result") == 1 - assert "Current Time:" in non_system[2]["content"] + assert "Current Time:" not in non_system[2]["content"] + assert non_system[2]["content"] == "subagent result" loop.sessions.invalidate("cli:test") persisted = loop.sessions.get_or_create("cli:test") diff --git a/tests/agent/test_runtime_context.py b/tests/agent/test_runtime_context.py new file mode 100644 index 00000000..815c952e --- /dev/null +++ b/tests/agent/test_runtime_context.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from nanobot.agent.tools.context import RequestContext +from nanobot.runtime_context import ( + RUNTIME_CONTEXT_HISTORY_META, + RuntimeContextBlock, + append_runtime_context, + public_history_message, + resolve_runtime_context, +) +from nanobot.sdk.types import snapshot_from_session +from nanobot.session.manager import Session, _message_preview_text +from nanobot.session.webui_turns import _title_inputs +from nanobot.webui.transcript import _session_user_event + + +@pytest.mark.asyncio +async def test_resolve_runtime_context_preserves_provider_order() -> None: + calls: list[str] = [] + + async def first(_request: RequestContext): + calls.append("first") + return RuntimeContextBlock(source="first", content="one") + + async def second(_request: RequestContext): + calls.append("second") + return [RuntimeContextBlock(source="second", content="two")] + + blocks = await resolve_runtime_context( + [first, second], + RequestContext(channel="cli", chat_id="direct"), + ) + + assert calls == ["first", "second"] + assert [(block.source, block.content) for block in blocks] == [ + ("first", "one"), + ("second", "two"), + ] + + +def test_public_history_removes_only_trusted_exact_suffix() -> None: + block = RuntimeContextBlock(source="goal", content="private goal context") + content, marker = append_runtime_context("visible user text", [block]) + assert marker is not None + persisted = { + "role": "user", + "content": content, + RUNTIME_CONTEXT_HISTORY_META: marker, + } + + assert public_history_message(persisted) == { + "role": "user", + "content": "visible user text", + } + + user_authored = { + "role": "user", + "content": "visible user text\n\nprivate goal context", + } + assert public_history_message(user_authored) == user_authored + + +def test_public_history_keeps_content_when_marker_does_not_match() -> None: + message = { + "role": "user", + "content": "user-edited content", + RUNTIME_CONTEXT_HISTORY_META: { + "version": 1, + "sources": ["goal"], + "suffix": "different suffix", + }, + } + + assert public_history_message(message) == { + "role": "user", + "content": "user-edited content", + } + + +def test_sdk_snapshot_hides_runtime_context() -> None: + block = RuntimeContextBlock(source="goal", content="private goal context") + content, marker = append_runtime_context("visible user text", [block]) + session = SimpleNamespace( + key="cli:direct", + created_at=SimpleNamespace(isoformat=lambda: "created"), + updated_at=SimpleNamespace(isoformat=lambda: "updated"), + metadata={}, + messages=[{ + "role": "user", + "content": content, + RUNTIME_CONTEXT_HISTORY_META: marker, + }], + ) + + snapshot = snapshot_from_session(session) + + assert snapshot.messages == [{"role": "user", "content": "visible user text"}] + + +def test_webui_preview_title_and_backfill_hide_runtime_context() -> None: + block = RuntimeContextBlock(source="goal", content="private goal context") + content, marker = append_runtime_context("visible user text", [block]) + persisted = { + "role": "user", + "content": content, + RUNTIME_CONTEXT_HISTORY_META: marker, + } + session = Session(key="websocket:chat", messages=[persisted]) + + assert _message_preview_text(persisted) == "visible user text" + assert _title_inputs(session) == ("visible user text", "") + event = _session_user_event("websocket:chat", persisted) + assert event is not None + assert event["text"] == "visible user text" diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index 066499c2..380b57da 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -1,3 +1,8 @@ +from nanobot.runtime_context import ( + RUNTIME_CONTEXT_HISTORY_META, + RuntimeContextBlock, + append_runtime_context, +) from nanobot.session.manager import Session, SessionManager @@ -425,6 +430,41 @@ def test_get_history_synthesizes_cli_app_attachment_breadcrumb(): }] +def test_get_history_does_not_duplicate_persisted_capability_runtime_context(): + content, marker = append_runtime_context( + "please use @drawio", + [RuntimeContextBlock( + source="cli_apps", + content="[Runtime Context]\nCLI App Attachment: @drawio", + ), RuntimeContextBlock( + source="mcp", + content="[Runtime Context]\nMCP Preset Attachment: @linear", + )], + ) + session = Session(key="test:cli-app-persisted") + session.messages.append({ + "role": "user", + "content": content, + "cli_apps": [{ + "name": "drawio", + "entry_point": "cli-anything-drawio", + }], + "mcp_presets": [{"name": "linear", "transport": "stdio"}], + RUNTIME_CONTEXT_HISTORY_META: marker, + }) + + model_history = session.get_history(max_messages=500) + public_history = session.get_history( + max_messages=500, + include_runtime_context=False, + ) + + assert model_history == [{"role": "user", "content": content}] + assert model_history[0]["content"].count("CLI App Attachment: @drawio") == 1 + assert model_history[0]["content"].count("MCP Preset Attachment: @linear") == 1 + assert public_history == [{"role": "user", "content": "please use @drawio"}] + + def test_fork_session_before_user_index_copies_only_prefix(tmp_path): manager = SessionManager(tmp_path) source = manager.get_or_create("websocket:source") diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index a20bb657..f6f54f83 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -21,6 +21,11 @@ from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig from nanobot.cron.service import CronService from nanobot.cron.types import CronJob, CronPayload, CronSchedule from nanobot.optional_features import InstallResult +from nanobot.runtime_context import ( + RUNTIME_CONTEXT_HISTORY_META, + RuntimeContextBlock, + append_runtime_context, +) from nanobot.session.keys import UNIFIED_SESSION_KEY from nanobot.session.manager import Session, SessionManager from nanobot.triggers.local_store import LocalTriggerStore @@ -1739,6 +1744,42 @@ async def test_session_routes_accept_percent_encoded_websocket_keys( await server_task +@pytest.mark.asyncio +async def test_session_messages_hide_persisted_runtime_context( + bus: MagicMock, tmp_path: Path +) -> None: + sm = SessionManager(tmp_path) + session = sm.get_or_create("websocket:runtime-context") + content, marker = append_runtime_context( + "visible user text", + [RuntimeContextBlock(source="goal", content="private goal context")], + ) + session.add_message( + "user", + content, + **{RUNTIME_CONTEXT_HISTORY_META: marker}, + ) + sm.save(session) + channel = _ch(bus, session_manager=sm, port=29919) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + token = channel.gateway.tokens.issue_api_token(300) + response = await _http_get( + "http://127.0.0.1:29919/api/sessions/websocket:runtime-context/messages", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + message = response.json()["messages"][0] + assert message["content"] == "visible user text" + assert RUNTIME_CONTEXT_HISTORY_META not in message + assert "private goal context" not in response.text + finally: + await channel.stop() + await server_task + + @pytest.mark.asyncio async def test_webui_thread_resigns_assistant_media_urls( bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch diff --git a/tests/cli_apps/test_tool.py b/tests/cli_apps/test_tool.py index 6ea26132..81738b9b 100644 --- a/tests/cli_apps/test_tool.py +++ b/tests/cli_apps/test_tool.py @@ -7,6 +7,7 @@ import time from pathlib import Path from nanobot.agent.tools.cli_apps import CliAppsTool +from nanobot.agent.tools.context import RequestContext from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig @@ -125,3 +126,33 @@ def test_run_cli_app_description_names_only_settings_installed_apps(tmp_path: Pa assert "Settings CLI Apps: drawio" in tool.description assert "ordinary system CLIs such as git, gh" in tool.description + + +def test_cli_app_tool_provides_context_only_for_attachment(tmp_path: Path) -> None: + tool = CliAppsTool(workspace=tmp_path) + provider = tool.runtime_context_provider() + assert provider is not None + + empty = asyncio.run(provider(RequestContext( + channel="websocket", + chat_id="chat", + original_user_text="hello", + workspace=tmp_path, + ))) + attached = asyncio.run(provider(RequestContext( + channel="websocket", + chat_id="chat", + original_user_text="use @drawio", + metadata={ + "cli_apps": [{ + "name": "drawio", + "entry_point": "cli-anything-drawio", + }], + }, + workspace=tmp_path, + ))) + + assert empty is None + assert attached is not None + assert attached.source == "cli_apps" + assert "CLI App Attachment: @drawio" in attached.content