feat(agent): add persistent runtime context providers

This commit is contained in:
chengyongru 2026-07-11 18:52:56 +08:00 committed by Xubin Ren
parent 7f8c3453e1
commit f75d3519db
27 changed files with 789 additions and 371 deletions

View File

@ -12,9 +12,14 @@ from nanobot.agent.tools import mcp as mcp_tools
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.cli import utils as cli_app_utils from nanobot.apps.cli import utils as cli_app_utils
from nanobot.bus.events import InboundMessage 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 ( from nanobot.utils.helpers import (
current_time_str,
detect_image_mime, detect_image_mime,
load_bundled_template, load_bundled_template,
truncate_text_to_tokens, 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) 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: async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
await mcp_tools.connect_missing_servers(state, tools) await mcp_tools.connect_missing_servers(state, tools)
@ -56,14 +48,10 @@ class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent.""" """Builds the context (system prompt + messages) for the agent."""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"] BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
_GOAL_RUNTIME_GUIDANCE_TAG = "[Goal Runtime Guidance — host instructions]" _RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
_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"
_MAX_RECENT_HISTORY = 50 _MAX_RECENT_HISTORY = 50
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens) _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): def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
self.workspace = workspace self.workspace = workspace
@ -139,45 +127,14 @@ class ContextBuilder:
channel=channel or "", 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 @staticmethod
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]: def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
if isinstance(left, str) and isinstance(right, str): 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]]: def _to_blocks(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list): if isinstance(value, list):
@ -221,65 +178,17 @@ class ContextBuilder:
sender_id: str | None = None, sender_id: str | None = None,
session_summary: str | None = None, session_summary: str | None = None,
session_metadata: Mapping[str, Any] | 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, 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, include_memory_recent_history: bool = True,
session_key: str | None = None, session_key: str | None = None,
unified_session: bool = False, unified_session: bool = False,
goal_start_requested: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call.""" """Build the complete message list for an LLM call."""
root = workspace or self.workspace 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) user_content = self._build_user_content(current_message, media)
goal_guidance = ( blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
self._build_goal_runtime_guidance( merged, runtime_context_meta = append_runtime_context(user_content, blocks)
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},
})
messages = [ messages = [
{ {
"role": "system", "role": "system",
@ -298,15 +207,15 @@ class ContextBuilder:
if messages[-1].get("role") == current_role: if messages[-1].get("role") == current_role:
last = dict(messages[-1]) last = dict(messages[-1])
last["content"] = self._merge_message_content(last.get("content"), merged) 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 = 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 last["_meta"] = internal_meta
messages[-1] = last messages[-1] = last
return messages return messages
current = {"role": current_role, "content": merged} current = {"role": current_role, "content": merged}
if current_role == "user" and isinstance(user_content, str): if current_role == "user" and runtime_context_meta is not None:
current["_meta"] = {self._HOST_TEXT_SUFFIX_META_KEY: host_text_suffix} current["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta}
messages.append(current) messages.append(current)
return messages return messages

View File

@ -12,6 +12,7 @@ from dataclasses import dataclass, field
from enum import Enum, auto from enum import Enum, auto
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, Awaitable, Callable from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger from loguru import logger
@ -27,6 +28,7 @@ from nanobot.agent.memory import Consolidator
from nanobot.agent.model_runtime import ModelRuntimeResolver from nanobot.agent.model_runtime import ModelRuntimeResolver
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
from nanobot.agent.subagent import SubagentManager 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.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.file_state import FileStateStore, bind_file_states, reset_file_states
from nanobot.agent.tools.message import MessageTool 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.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot 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 ( from nanobot.security.workspace_access import (
WorkspaceScopeResolver, WorkspaceScopeResolver,
bind_workspace_scope, bind_workspace_scope,
@ -60,7 +71,6 @@ from nanobot.security.workspace_access import (
from nanobot.session import turn_continuation from nanobot.session import turn_continuation
from nanobot.session.automation_turns import automation_history_overrides from nanobot.session.automation_turns import automation_history_overrides
from nanobot.session.goal_state import ( from nanobot.session.goal_state import (
explicit_goal_requested,
goal_state_runtime_lines, goal_state_runtime_lines,
runner_wall_llm_timeout_s, runner_wall_llm_timeout_s,
sustained_goal_active, sustained_goal_active,
@ -123,6 +133,8 @@ class TurnContext:
history: list[dict[str, Any]] = field(default_factory=list) history: list[dict[str, Any]] = field(default_factory=list)
initial_messages: 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 final_content: str | None = None
tools_used: list[str] = field(default_factory=list) tools_used: list[str] = field(default_factory=list)
@ -365,6 +377,8 @@ class AgentLoop:
self._mcp_servers = mcp_servers or {} self._mcp_servers = mcp_servers or {}
self._mcp_stacks: dict[str, MCPConnection] = {} self._mcp_stacks: dict[str, MCPConnection] = {}
self._mcp_connecting = False 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._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
self._background_tasks: list[asyncio.Task] = [] self._background_tasks: list[asyncio.Task] = []
self._session_locks: dict[str, asyncio.Lock] = {} self._session_locks: dict[str, asyncio.Lock] = {}
@ -554,6 +568,14 @@ 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 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 @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."""
@ -608,6 +630,7 @@ class AgentLoop:
self, self,
msg: InboundMessage, msg: InboundMessage,
session: Session, session: Session,
runtime_context_blocks: list[RuntimeContextBlock] | None = None,
**kwargs: Any, **kwargs: Any,
) -> bool: ) -> bool:
"""Persist the triggering user message before the turn starts. """Persist the triggering user message before the turn starts.
@ -618,7 +641,7 @@ class AgentLoop:
return False return False
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p] 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() 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: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra.update(kwargs) extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else "" text = msg.content if isinstance(msg.content, str) else ""
@ -626,6 +649,12 @@ class AgentLoop:
if text_override is not None: if text_override is not None:
text = text_override text = text_override
extra.update(automation_extra) 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) session.add_message("user", text, **extra)
self._mark_pending_user_turn(session) self._mark_pending_user_turn(session)
self.sessions.save(session) self.sessions.save(session)
@ -639,7 +668,7 @@ class AgentLoop:
history: list[dict[str, Any]], history: list[dict[str, Any]],
pending_summary: str | None, pending_summary: str | None,
include_memory_recent_history: bool = True, include_memory_recent_history: bool = True,
goal_start_requested: bool = False, runtime_context_blocks: list[RuntimeContextBlock] | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Build the initial message list for the LLM turn.""" """Build the initial message list for the LLM turn."""
scope = self.workspace_scopes.for_message(msg, session.metadata) scope = self.workspace_scopes.for_message(msg, session.metadata)
@ -653,14 +682,53 @@ class AgentLoop:
session_summary=pending_summary, session_summary=pending_summary,
session_metadata=session.metadata, session_metadata=session.metadata,
workspace=scope.project_path, workspace=scope.project_path,
runtime_state=self, runtime_context_blocks=runtime_context_blocks,
inbound_message=msg,
goal_start_requested=goal_start_requested,
include_memory_recent_history=include_memory_recent_history, include_memory_recent_history=include_memory_recent_history,
session_key=session.key, session_key=session.key,
unified_session=self._unified_session, 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( async def _dispatch_command_inline(
self, self,
msg: InboundMessage, msg: InboundMessage,
@ -731,6 +799,7 @@ class AgentLoop:
hook_factories: list[AgentTurnHookFactory] | None = None, hook_factories: list[AgentTurnHookFactory] | None = None,
turn_scopes: list[AbstractContextManager[Any]] | None = None, turn_scopes: list[AbstractContextManager[Any]] | None = None,
tools: ToolRegistry | None = None, tools: ToolRegistry | None = None,
request_context: RequestContext | None = None,
) -> tuple[str | None, list[str], list[dict], str, bool]: ) -> tuple[str | None, list[str], list[dict], str, bool]:
"""Run the agent iteration loop. """Run the agent iteration loop.
@ -819,7 +888,7 @@ 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
request_ctx = RequestContext( request_ctx = request_context or RequestContext(
channel=channel, channel=channel,
chat_id=chat_id, chat_id=chat_id,
message_id=message_id, message_id=message_id,
@ -827,6 +896,7 @@ class AgentLoop:
original_user_text=original_user_text, original_user_text=original_user_text,
runtime=runtime, runtime=runtime,
metadata=dict(metadata or {}), metadata=dict(metadata or {}),
workspace=effective_scope.project_path,
) )
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key)) file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
request_token = bind_request_context(request_ctx) request_token = bind_request_context(request_ctx)
@ -1259,9 +1329,6 @@ class AgentLoop:
session_summary=pending, session_summary=pending,
session_metadata=session.metadata, session_metadata=session.metadata,
workspace=workspace_scope.project_path, workspace=workspace_scope.project_path,
runtime_state=self,
inbound_message=msg,
skip_runtime_lines=is_subagent,
session_key=key, session_key=key,
unified_session=self._unified_session, unified_session=self._unified_session,
) )
@ -1561,16 +1628,20 @@ class AgentLoop:
ctx.runtime, 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.initial_messages = self._build_initial_messages(
ctx.msg, ctx.msg,
ctx.session, ctx.session,
ctx.history, ctx.history,
ctx.pending_summary, ctx.pending_summary,
include_memory_recent_history=not ctx.ephemeral, 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.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: if ctx.on_progress is None:
@ -1610,6 +1681,7 @@ class AgentLoop:
hook_factories=ctx.hook_factories, hook_factories=ctx.hook_factories,
turn_scopes=ctx.turn_scopes, turn_scopes=ctx.turn_scopes,
tools=ctx.tools, tools=ctx.tools,
request_context=ctx.request_context,
) )
final_content, tools_used, all_msgs, stop_reason, had_injections = result final_content, tools_used, all_msgs, stop_reason, had_injections = result
ctx.final_content = final_content ctx.final_content = final_content
@ -1684,7 +1756,6 @@ class AgentLoop:
content: list[dict[str, Any]], content: list[dict[str, Any]],
*, *,
should_truncate_text: bool = False, should_truncate_text: bool = False,
drop_runtime: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Strip volatile multimodal payloads before writing session history.""" """Strip volatile multimodal payloads before writing session history."""
filtered: list[dict[str, Any]] = [] filtered: list[dict[str, Any]] = []
@ -1693,14 +1764,6 @@ class AgentLoop:
filtered.append(block) filtered.append(block)
continue 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( if block.get("type") == "image_url" and block.get("image_url", {}).get(
"url", "" "url", ""
).startswith("data:image/"): ).startswith("data:image/"):
@ -1741,8 +1804,8 @@ class AgentLoop:
for m in messages[skip:]: for m in messages[skip:]:
entry = dict(m) entry = dict(m)
internal_meta = entry.pop("_meta", None) internal_meta = entry.pop("_meta", None)
host_text_suffix = ( runtime_context_meta = (
internal_meta.get(ContextBuilder._HOST_TEXT_SUFFIX_META_KEY) internal_meta.get(RUNTIME_CONTEXT_MESSAGE_META)
if isinstance(internal_meta, dict) if isinstance(internal_meta, dict)
else None else None
) )
@ -1770,24 +1833,13 @@ class AgentLoop:
] ]
entry["content"] = filtered entry["content"] = filtered
elif role == "user": 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): if isinstance(content, list):
filtered = self._sanitize_persisted_blocks(content, drop_runtime=True) filtered = self._sanitize_persisted_blocks(content)
if not filtered: if not filtered:
continue continue
entry["content"] = filtered entry["content"] = filtered
if isinstance(runtime_context_meta, dict):
entry[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
entry.setdefault("timestamp", datetime.now().isoformat()) entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry) session.messages.append(entry)
if role == "assistant": if role == "assistant":

View File

@ -433,10 +433,8 @@ class SubagentManager:
def _build_subagent_prompt(self, workspace: Path | None = None) -> str: def _build_subagent_prompt(self, workspace: Path | None = None) -> str:
"""Build a focused system prompt for the subagent.""" """Build a focused system prompt for the subagent."""
from nanobot.agent.context import ContextBuilder
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
time_ctx = ContextBuilder._build_runtime_context(None, None)
root = workspace or self.workspace root = workspace or self.workspace
skills_summary = SkillsLoader( skills_summary = SkillsLoader(
root, root,
@ -444,7 +442,6 @@ class SubagentManager:
).build_skills_summary() ).build_skills_summary()
return render_template( return render_template(
"agent/subagent_system.md", "agent/subagent_system.md",
time_ctx=time_ctx,
workspace=str(root), workspace=str(root),
skills_summary=skills_summary or "", skills_summary=skills_summary or "",
) )

View File

@ -11,6 +11,7 @@ if typing.TYPE_CHECKING:
from pydantic import BaseModel from pydantic import BaseModel
from nanobot.agent.tools.context import ToolContext from nanobot.agent.tools.context import ToolContext
from nanobot.runtime_context import RuntimeContextProvider
_ToolT = TypeVar("_ToolT", bound="Tool") _ToolT = TypeVar("_ToolT", bound="Tool")
@ -206,6 +207,10 @@ class Tool(ABC):
def create(cls, ctx: ToolContext) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
return cls() return cls()
def runtime_context_provider(self) -> RuntimeContextProvider | None:
"""Return optional per-turn prompt context owned by this tool."""
return None
@abstractmethod @abstractmethod
async def execute(self, **kwargs: Any) -> Any: async def execute(self, **kwargs: Any) -> Any:
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures.""" """Run the tool; return content, or ``ToolResult.error(...)`` for failures."""

View File

@ -8,6 +8,7 @@ from typing import Any
from pydantic import Field from pydantic import Field
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 RequestContext
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
ArraySchema, ArraySchema,
BooleanSchema, BooleanSchema,
@ -16,7 +17,9 @@ from nanobot.agent.tools.schema import (
tool_parameters_schema, tool_parameters_schema,
) )
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig 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.config_base import Base
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
from nanobot.security.workspace_access import current_tool_workspace from nanobot.security.workspace_access import current_tool_workspace
@ -112,6 +115,23 @@ class CliAppsTool(Tool):
+ installed_note + 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( async def execute(
self, self,
name: str, name: str,

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from contextlib import contextmanager 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 pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
if TYPE_CHECKING: if TYPE_CHECKING:
@ -25,6 +26,9 @@ class RequestContext:
original_user_text: str | None = None original_user_text: str | None = None
runtime: LLMRuntime | None = None runtime: LLMRuntime | None = None
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
sender_id: str | None = None
turn_id: str | None = None
workspace: Path | None = None
@runtime_checkable @runtime_checkable

View File

@ -138,6 +138,9 @@ class _LegacyErrorPrefixTool(Tool):
def parameters(self) -> dict[str, Any]: def parameters(self) -> dict[str, Any]:
return self._wrapped.parameters return self._wrapped.parameters
def runtime_context_provider(self):
return self._wrapped.runtime_context_provider()
@property @property
def read_only(self) -> bool: def read_only(self) -> bool:
return self._wrapped.read_only return self._wrapped.read_only

View File

@ -11,17 +11,22 @@ from nanobot.agent.goal_permission import (
revoke_goal_mutation_permission, revoke_goal_mutation_permission,
) )
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 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.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.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
from nanobot.session.goal_state import ( from nanobot.session.goal_state import (
GOAL_STATE_KEY, GOAL_STATE_KEY,
MAX_GOAL_OBJECTIVE_CHARS, MAX_GOAL_OBJECTIVE_CHARS,
discard_legacy_goal_state_key, discard_legacy_goal_state_key,
explicit_goal_requested,
goal_state_raw, goal_state_raw,
goal_state_runtime_lines,
parse_goal_state, parse_goal_state,
sustained_goal_active,
) )
from nanobot.session.turn_continuation import reset_goal_continuation_rounds from nanobot.session.turn_continuation import reset_goal_continuation_rounds
from nanobot.utils.prompt_templates import render_template
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
@ -158,6 +163,31 @@ class CreateGoalTool(Tool, _GoalToolsMixin):
"completion criteria. Do not retry after a successful creation." "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( async def execute(
self, self,
objective: str, objective: str,

View File

@ -1,11 +1,16 @@
"""Tool registry for dynamic tool management.""" """Tool registry for dynamic tool management."""
from __future__ import annotations
import json import json
from typing import Any from typing import TYPE_CHECKING, 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 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: def is_tool_error_result(name: str, result: Any) -> bool:
return isinstance(result, ToolResult) and result.is_error return isinstance(result, ToolResult) and result.is_error
@ -36,6 +41,15 @@ class ToolRegistry:
"""Get a tool by name.""" """Get a tool by name."""
return self._tools.get(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 @staticmethod
def _lookup_key(name: str) -> str: def _lookup_key(name: str) -> str:
"""Normalize names for suggestions only; never for execution.""" """Normalize names for suggestions only; never for execution."""

View File

@ -18,14 +18,15 @@ def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[
return [] return []
text = message.content if isinstance(getattr(message, "content", None), str) else "" text = message.content if isinstance(getattr(message, "content", None), str) else ""
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None 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, text: str,
metadata: Mapping[str, Any] | None, metadata: Mapping[str, Any] | None,
workspace: Path, workspace: Path,
) -> list[str]: ) -> list[str]:
"""Return CLI App annotations from an immutable request snapshot."""
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
if isinstance(structured, list): if isinstance(structured, list):
mentions = [ mentions = [

View File

@ -747,7 +747,7 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage:
) )
session = ctx.session or ctx.loop.sessions.get_or_create(ctx.key) 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 = [_format_history_message(m) for m in history]
visible = [m for m in visible if m is not None] visible = [m for m in visible if m is not None]
recent = visible[-count:] recent = visible[-count:]

126
nanobot/runtime_context.py Normal file
View File

@ -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]

View File

@ -7,6 +7,7 @@ from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from nanobot.runtime_context import RUNTIME_CONTEXT_HISTORY_META
from nanobot.sdk.types import ( from nanobot.sdk.types import (
SessionInfo, SessionInfo,
SessionSnapshot, SessionSnapshot,
@ -22,7 +23,7 @@ if TYPE_CHECKING:
class SessionClient: class SessionClient:
"""Session management helpers exposed through ``bot.sessions``.""" """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"} _VALID_ROLES = {"user", "assistant", "tool", "system"}
def __init__(self, loop: AgentLoop) -> None: def __init__(self, loop: AgentLoop) -> None:

View File

@ -6,6 +6,8 @@ from copy import deepcopy
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Literal, Mapping, TypeAlias from typing import Any, Literal, Mapping, TypeAlias
from nanobot.runtime_context import public_history_messages
StreamEventType: TypeAlias = Literal[ StreamEventType: TypeAlias = Literal[
"run.started", "run.started",
"text.delta", "text.delta",
@ -125,7 +127,7 @@ def snapshot_from_session(session: Any) -> SessionSnapshot:
created_at=session.created_at.isoformat(), created_at=session.created_at.isoformat(),
updated_at=session.updated_at.isoformat(), updated_at=session.updated_at.isoformat(),
metadata=deepcopy(session.metadata), 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"), created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"), updated_at=payload.get("updated_at"),
metadata=deepcopy(dict(payload.get("metadata") or {})), 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)
),
) )

View File

@ -15,6 +15,10 @@ from typing import Any
from loguru import logger from loguru import logger
from nanobot.config.paths import get_legacy_sessions_dir 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 ( from nanobot.utils.helpers import (
ensure_dir, ensure_dir,
estimate_message_tokens, estimate_message_tokens,
@ -93,6 +97,7 @@ def _text_preview(content: Any) -> str:
def _message_preview_text(message: dict[str, Any]) -> str: def _message_preview_text(message: dict[str, Any]) -> str:
"""Session list preview text; subagent inject blobs are shortened for display.""" """Session list preview text; subagent inject blobs are shortened for display."""
message = public_history_message(message)
content: Any = message.get("content") content: Any = message.get("content")
if message.get("injected_event") == "subagent_result" and isinstance(content, str): if message.get("injected_event") == "subagent_result" and isinstance(content, str):
content = scrub_subagent_announce_body(content) content = scrub_subagent_announce_body(content)
@ -153,6 +158,7 @@ class Session:
*, *,
max_tokens: int = 0, max_tokens: int = 0,
extend_to_user: bool = False, extend_to_user: bool = False,
include_runtime_context: bool = True,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Return unconsolidated messages for LLM input. """Return unconsolidated messages for LLM input.
@ -187,6 +193,12 @@ class Session:
for message in sliced: for message in sliced:
if message.get("_command"): if message.get("_command"):
continue 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", "") content = message.get("content", "")
role = message.get("role") role = message.get("role")
if role == "assistant" and isinstance(content, str): if role == "assistant" and isinstance(content, str):
@ -203,7 +215,13 @@ class Session:
) )
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
cli_apps = message.get("cli_apps") 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] = [] cli_lines: list[str] = []
for item in cli_apps[:8]: for item in cli_apps[:8]:
if not isinstance(item, dict): if not isinstance(item, dict):
@ -221,7 +239,8 @@ class Session:
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
mcp_presets = message.get("mcp_presets") mcp_presets = message.get("mcp_presets")
if ( if (
role == "user" not has_persisted_runtime_context
and role == "user"
and isinstance(mcp_presets, list) and isinstance(mcp_presets, list)
and mcp_presets and mcp_presets
and isinstance(content, str) and isinstance(content, str)

View File

@ -31,6 +31,7 @@ from nanobot.bus.runtime_events import (
TurnRunStatusChanged, TurnRunStatusChanged,
) )
from nanobot.providers.base import LLMProvider 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.goal_state import goal_state_ws_blob
from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
@ -79,6 +80,7 @@ def _title_inputs(session: Session) -> tuple[str, str]:
continue continue
if is_hidden_history_message(message): if is_hidden_history_message(message):
continue continue
message = public_history_message(message)
role = message.get("role") role = message.get("role")
content = message.get("content") content = message.get("content")
if not isinstance(content, str) or not content.strip(): if not isinstance(content, str) or not content.strip():

View File

@ -1,7 +1,5 @@
# Subagent # Subagent
{{ time_ctx }}
You are a subagent spawned by the main agent to complete a specific task. 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. Stay focused on the assigned task. Your final response will be reported back to the main agent.

View File

@ -17,6 +17,7 @@ from urllib.parse import unquote, urlparse
from loguru import logger from loguru import logger
from nanobot.config.paths import get_webui_dir 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.automation_turns import is_automation_kind
from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
@ -785,6 +786,7 @@ def write_session_messages_as_transcript(
for msg in messages: for msg in messages:
if is_hidden_history_message(msg): if is_hidden_history_message(msg):
continue continue
msg = public_history_message(msg)
role = msg.get("role") role = msg.get("role")
content = msg.get("content") content = msg.get("content")
text = content if isinstance(content, str) else "" text = content if isinstance(content, str) else ""
@ -876,6 +878,7 @@ def _session_user_event(
return None return None
if is_hidden_history_message(message): if is_hidden_history_message(message):
return None return None
message = public_history_message(message)
if _is_legacy_raw_subagent_result(message): if _is_legacy_raw_subagent_result(message):
return None return None
content = message.get("content") content = message.get("content")

View File

@ -26,6 +26,7 @@ from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette from nanobot.command.builtin import builtin_command_palette
from nanobot.cron.session_turns import is_bound_cron_job from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import CronJob, CronSchedule from nanobot.cron.types import CronJob, CronSchedule
from nanobot.runtime_context import public_history_messages
from nanobot.triggers.local_types import LocalTrigger from nanobot.triggers.local_types import LocalTrigger
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
@ -426,6 +427,9 @@ class GatewayHTTPHandler:
messages = data.get("messages") messages = data.get("messages")
if isinstance(messages, list): if isinstance(messages, list):
scrub_subagent_messages_for_channel(messages) 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) self.media.augment_media_urls(data)
return _http_json_response(data) return _http_json_response(data)

View File

@ -5,8 +5,7 @@ from pathlib import Path
import pytest import pytest
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.bus.events import InboundMessage from nanobot.runtime_context import RuntimeContextBlock
from nanobot.session.goal_state import GOAL_STATE_KEY
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@ -16,42 +15,6 @@ def _builder(tmp_path: Path, **kw) -> ContextBuilder:
return ContextBuilder(workspace=tmp_path, **kw) 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) # _merge_message_content (static)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -315,118 +278,46 @@ class TestBuildMessages:
assert messages[1]["role"] == "user" assert messages[1]["role"] == "user"
assert "hello" in str(messages[1]["content"]) 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) builder = _builder(tmp_path)
messages = builder.build_messages([], "hello", channel="cli", chat_id="direct") messages = builder.build_messages([], "hello", channel="cli", chat_id="direct")
user_msg = str(messages[-1]["content"]) user_msg = str(messages[-1]["content"])
assert "[Runtime Context" in user_msg assert user_msg == "hello"
assert "hello" in user_msg 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) builder = _builder(tmp_path)
meta = {
GOAL_STATE_KEY: {"status": "active", "objective": "Finish docs migration."},
}
messages = builder.build_messages( messages = builder.build_messages(
[], [],
"hi", "hi",
channel="cli", channel="cli",
chat_id="x", chat_id="x",
session_metadata=meta, session_metadata={"goal_state": {"status": "active", "objective": "hidden"}},
) )
user_msg = str(messages[-1]["content"]) assert messages[-1]["content"] == "hi"
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
def test_goal_start_turn_injects_objective_guidance_after_user_text(self, tmp_path): def test_explicit_runtime_context_blocks_are_appended(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 <task>` 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):
builder = _builder(tmp_path) builder = _builder(tmp_path)
messages = builder.build_messages( messages = builder.build_messages(
[], [],
"please use @zoom tonight", "please use @zoom tonight",
current_runtime_lines=[ runtime_context_blocks=[
"CLI App Attachment: @zoom (installed; tool=run_cli_app; entry_point=cli-anything-zoom).", RuntimeContextBlock(
source="cli_apps",
content="CLI App Attachment: @zoom (installed; tool=run_cli_app).",
),
], ],
) )
user_msg = str(messages[-1]["content"]) user_msg = str(messages[-1]["content"])
assert "CLI App Attachment: @zoom" in user_msg assert "CLI App Attachment: @zoom" in user_msg
assert "tool=run_cli_app" 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): def test_consecutive_same_role_merged(self, tmp_path):
builder = _builder(tmp_path) builder = _builder(tmp_path)

View File

@ -9,6 +9,7 @@ from importlib.resources import files as pkg_files
from pathlib import Path from pathlib import Path
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.runtime_context import RuntimeContextBlock
class _FakeDatetime(real_datetime): 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 assert "write important facts here" not in prompt
def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: def test_default_user_message_has_no_runtime_context(tmp_path) -> None:
"""Runtime metadata should be merged with the user message."""
workspace = _make_workspace(tmp_path) workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace) 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 messages[0]["role"] == "system"
assert "## Current Session" not in messages[0]["content"] 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" assert messages[-1]["role"] == "user"
user_content = messages[-1]["content"] user_content = messages[-1]["content"]
assert isinstance(user_content, str) assert user_content == "Return exactly: OK"
assert ContextBuilder._RUNTIME_CONTEXT_TAG in user_content assert "_meta" not in messages[-1]
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
def test_runtime_context_appended_after_user_content(tmp_path) -> None: def test_provider_context_appended_after_user_content(tmp_path) -> None:
"""User content must precede runtime context for prompt-cache prefix stability."""
workspace = _make_workspace(tmp_path) workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace) builder = ContextBuilder(workspace)
@ -97,16 +91,18 @@ def test_runtime_context_appended_after_user_content(tmp_path) -> None:
current_message="hello world", current_message="hello world",
channel="cli", channel="cli",
chat_id="direct", chat_id="direct",
runtime_context_blocks=[
RuntimeContextBlock(source="test", content="provider context"),
],
) )
content = messages[-1]["content"] content = messages[-1]["content"]
user_pos = content.find("hello world") user_pos = content.find("hello world")
tag_pos = content.find(ContextBuilder._RUNTIME_CONTEXT_TAG) context_pos = content.find("provider context")
assert user_pos < tag_pos, "user content must precede runtime context for prefix stability" assert user_pos < context_pos, "user content must precede provider context"
def test_runtime_context_includes_sender_id_when_provided(tmp_path) -> None: def test_sender_id_is_not_injected_without_provider(tmp_path) -> None:
"""Sender ID should be included in runtime context when provided."""
workspace = _make_workspace(tmp_path) workspace = _make_workspace(tmp_path)
builder = ContextBuilder(workspace) builder = ContextBuilder(workspace)
@ -120,7 +116,8 @@ def test_runtime_context_includes_sender_id_when_provided(tmp_path) -> None:
user_content = messages[-1]["content"] user_content = messages[-1]["content"]
assert isinstance(user_content, str) 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: def test_runtime_context_excludes_sender_id_when_not_provided(tmp_path) -> None:

View File

@ -7,15 +7,16 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from nanobot.agent.context import ContextBuilder
from nanobot.agent.goal_permission import goal_mutation_allowed, goal_mutation_permission from nanobot.agent.goal_permission import goal_mutation_allowed, goal_mutation_permission
from nanobot.bus.outbound_events import StreamedResponseEvent from nanobot.bus.outbound_events import StreamedResponseEvent
from nanobot.config.schema import AgentDefaults 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.session.goal_state import GOAL_STATE_KEY
from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.llm_runtime import LLMRuntime
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
_GOAL_RUNTIME_GUIDANCE_TAG = "[Goal Runtime Guidance — host instructions]"
def _make_loop(tmp_path): 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"] first_request = provider.chat_with_retry.await_args_list[0].kwargs["messages"]
assert "staged migration plan" in str(first_request) assert "staged migration plan" in str(first_request)
assert "/goal implement the plan above" 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"] 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 "create_goal is unavailable for this turn" in str(final_request)
assert all( assert _GOAL_RUNTIME_GUIDANCE_TAG in str(session.messages[2]["content"])
ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG not in str(message.get("content") or "") assert _GOAL_RUNTIME_GUIDANCE_TAG not in str(
for message in session.messages 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 @pytest.mark.asyncio
async def test_non_goal_direct_turn_cannot_reuse_prior_goal_command(tmp_path): async def test_non_goal_direct_turn_cannot_reuse_prior_goal_command(tmp_path):
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop

View File

@ -18,8 +18,15 @@ from nanobot.bus.outbound_events import (
) )
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META 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.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.automation_turns import AUTOMATION_HISTORY_META
from nanobot.session.goal_state import GOAL_STATE_KEY from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
@ -48,19 +55,13 @@ def _mk_loop() -> AgentLoop:
return loop 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 { return {
"role": "user", "role": "user",
"content": content, "content": merged,
"_meta": {ContextBuilder._HOST_TEXT_SUFFIX_META_KEY: suffix}, "_meta": {RUNTIME_CONTEXT_MESSAGE_META: marker},
}
def _host_text_block(text: str) -> dict:
return {
"type": "text",
"text": text,
"_meta": {ContextBuilder._HOST_BLOCK_META_KEY: True},
} }
@ -357,74 +358,80 @@ def test_webui_title_update_uses_captured_llm_runtime(
assert captured["model"] == "turn-model" 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() loop = _mk_loop()
session = Session(key="test:runtime-only") 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( loop._save_turn(
session, session,
[{"role": "user", "content": [_host_text_block(runtime)]}], [_runtime_message([], [block])],
skip=0, 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() loop = _mk_loop()
session = Session(key="test:image") session = Session(key="test:image")
runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)" block = RuntimeContextBlock(source="test", content="provider context")
loop._save_turn( loop._save_turn(
session, session,
[{ [_runtime_message(
"role": "user", [
"content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}, "_meta": {"path": "/media/feishu/photo.jpg"}}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}, "_meta": {"path": "/media/feishu/photo.jpg"}},
_host_text_block(runtime),
], ],
}], [block],
)],
skip=0, 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: def test_save_turn_keeps_image_placeholder_without_meta() -> None:
loop = _mk_loop() loop = _mk_loop()
session = Session(key="test:image-no-meta") 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( loop._save_turn(
session, session,
[{ [_runtime_message(
"role": "user", [
"content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
_host_text_block(runtime),
], ],
}], [block],
)],
skip=0, 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() loop = _mk_loop()
session = Session(key="test:suffix-strip") session = Session(key="test:suffix-strip")
guidance = ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG + "\ninternal guidance" block = RuntimeContextBlock(source="goal", content="internal goal guidance")
runtime = (
ContextBuilder._RUNTIME_CONTEXT_TAG
+ "\nCurrent Time: now\n"
+ ContextBuilder._RUNTIME_CONTEXT_END
)
suffix = f"{guidance}\n\n{runtime}"
loop._save_turn( loop._save_turn(
session, session,
[_host_text_message(f"hello world\n\n{suffix}", suffix)], [_runtime_message("hello world", [block])],
skip=0, 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: 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") session = Session(key="test:user-guidance-literal")
user_text = ( user_text = (
"Keep this prefix\n" "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." "This label and everything after it are user-authored."
) )
messages = ContextBuilder(tmp_path).build_messages( 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, user_text,
channel="cli", channel="cli",
chat_id="direct", chat_id="direct",
goal_start_requested=True,
) )
assert "_meta" in messages[-1] assert "_meta" not in messages[-1]
assert "_meta" not in LLMProvider._sanitize_empty_content(messages)[-1]
loop._save_turn(session, messages, skip=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)], media=[str(image)],
channel="cli", channel="cli",
chat_id="direct", chat_id="direct",
goal_start_requested=True,
) )
loop._save_turn(session, messages, skip=1) 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"] 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() loop = _mk_loop()
session = Session(key="test:suffix-only") session = Session(key="test:suffix-only")
runtime = ( block = RuntimeContextBlock(source="test", content="provider context")
ContextBuilder._RUNTIME_CONTEXT_TAG
+ "\nCurrent Time: now\n"
+ ContextBuilder._RUNTIME_CONTEXT_END
)
loop._save_turn( loop._save_turn(
session, session,
[_host_text_message(runtime, runtime)], [_runtime_message("", [block])],
skip=0, 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: 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 assert "Finish the long goal." in queued.content
session = loop.sessions.get_or_create("feishu:c-auto") session = loop.sessions.get_or_create("feishu:c-auto")
assert "Finish the long goal." in str(session.messages[0]["content"])
assert [ assert [
{k: v for k, v in m.items() if k in {"role", "content"}} {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": "user", "content": "start the goal"}]
second = await loop._process_message(queued, pending_queue=asyncio.Queue()) 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") session = loop.sessions.get_or_create("feishu:c-auto")
assert [ assert [
{k: v for k, v in m.items() if k in {"role", "content"}} {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": "user", "content": "start the goal"},
{"role": "assistant", "content": "done"}, {"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[0]["content"]
assert "[Message Time:" not in non_system[1]["content"] assert "[Message Time:" not in non_system[1]["content"]
assert non_system[2]["content"].count("subagent result") == 1 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") loop.sessions.invalidate("cli:test")
persisted = loop.sessions.get_or_create("cli:test") persisted = loop.sessions.get_or_create("cli:test")

View File

@ -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"

View File

@ -1,3 +1,8 @@
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
RuntimeContextBlock,
append_runtime_context,
)
from nanobot.session.manager import Session, SessionManager 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): def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source") source = manager.get_or_create("websocket:source")

View File

@ -21,6 +21,11 @@ from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.optional_features import InstallResult 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.keys import UNIFIED_SESSION_KEY
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.triggers.local_store import LocalTriggerStore from nanobot.triggers.local_store import LocalTriggerStore
@ -1739,6 +1744,42 @@ async def test_session_routes_accept_percent_encoded_websocket_keys(
await server_task 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 @pytest.mark.asyncio
async def test_webui_thread_resigns_assistant_media_urls( async def test_webui_thread_resigns_assistant_media_urls(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch

View File

@ -7,6 +7,7 @@ import time
from pathlib import Path from pathlib import Path
from nanobot.agent.tools.cli_apps import CliAppsTool from nanobot.agent.tools.cli_apps import CliAppsTool
from nanobot.agent.tools.context import RequestContext
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig 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 "Settings CLI Apps: drawio" in tool.description
assert "ordinary system CLIs such as git, gh" 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