feat(agent): add persistent runtime context providers

This commit is contained in:
chengyongru
2026-07-12 00:35:17 +08:00
committed by Xubin Ren
parent 7f8c3453e1
commit f75d3519db
27 changed files with 789 additions and 371 deletions
+21 -112
View File
@@ -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
+89 -37
View File
@@ -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":
-3
View File
@@ -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 "",
)
+5
View File
@@ -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."""
+20
View File
@@ -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,
+4
View File
@@ -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
+3
View File
@@ -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
+31 -1
View File
@@ -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,
+15 -1
View File
@@ -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."""
+3 -2
View File
@@ -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 = [
+1 -1
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)
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:]
+126
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]
+2 -1
View File
@@ -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:
+8 -2
View File
@@ -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)
),
)
+21 -2
View File
@@ -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)
+2
View File
@@ -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():
@@ -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.
+3
View File
@@ -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")
+4
View File
@@ -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)
+19 -128
View File
@@ -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 <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):
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)
+13 -16
View File
@@ -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:
+109 -6
View File
@@ -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
+60 -57
View File
@@ -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")
+118
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"
@@ -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")
@@ -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
+31
View File
@@ -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