feat(agent): add persistent runtime context providers
This commit is contained in:
+21
-112
@@ -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
@@ -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":
|
||||
|
||||
@@ -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 "",
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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:]
|
||||
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user