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)