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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user