mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
feat(agent): add persistent runtime context providers
This commit is contained in:
parent
7f8c3453e1
commit
f75d3519db
@ -12,9 +12,14 @@ from nanobot.agent.tools import mcp as mcp_tools
|
|||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.cli import utils as cli_app_utils
|
from nanobot.apps.cli import utils as cli_app_utils
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.session.goal_state import goal_state_runtime_lines, sustained_goal_active
|
from nanobot.runtime_context import (
|
||||||
|
RUNTIME_CONTEXT_END,
|
||||||
|
RUNTIME_CONTEXT_MESSAGE_META,
|
||||||
|
RUNTIME_CONTEXT_TAG,
|
||||||
|
RuntimeContextBlock,
|
||||||
|
append_runtime_context,
|
||||||
|
)
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
current_time_str,
|
|
||||||
detect_image_mime,
|
detect_image_mime,
|
||||||
load_bundled_template,
|
load_bundled_template,
|
||||||
truncate_text_to_tokens,
|
truncate_text_to_tokens,
|
||||||
@ -27,19 +32,6 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
|||||||
return cli_app_utils.session_extra(metadata) | mcp_tools.session_extra(metadata)
|
return cli_app_utils.session_extra(metadata) | mcp_tools.session_extra(metadata)
|
||||||
|
|
||||||
|
|
||||||
def runtime_lines(state: Any, msg: Any, workspace: Path, *, skip: bool = False) -> list[str]:
|
|
||||||
"""Return model-visible runtime annotations for turn-attached capabilities."""
|
|
||||||
return [
|
|
||||||
*cli_app_utils.runtime_lines(msg, workspace, skip=skip),
|
|
||||||
*mcp_tools.runtime_lines(
|
|
||||||
msg,
|
|
||||||
configured_server_names=set(state._mcp_servers),
|
|
||||||
connected_server_names=set(state._mcp_stacks),
|
|
||||||
skip=skip,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
|
async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
|
||||||
await mcp_tools.connect_missing_servers(state, tools)
|
await mcp_tools.connect_missing_servers(state, tools)
|
||||||
|
|
||||||
@ -56,14 +48,10 @@ class ContextBuilder:
|
|||||||
"""Builds the context (system prompt + messages) for the agent."""
|
"""Builds the context (system prompt + messages) for the agent."""
|
||||||
|
|
||||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
||||||
_GOAL_RUNTIME_GUIDANCE_TAG = "[Goal Runtime Guidance — host instructions]"
|
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
|
||||||
_GOAL_RUNTIME_GUIDANCE_END = "[/Goal Runtime Guidance]"
|
|
||||||
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
|
||||||
_HOST_TEXT_SUFFIX_META_KEY = "host_text_suffix"
|
|
||||||
_HOST_BLOCK_META_KEY = "nanobot_host_content"
|
|
||||||
_MAX_RECENT_HISTORY = 50
|
_MAX_RECENT_HISTORY = 50
|
||||||
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
||||||
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
|
_RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
|
||||||
|
|
||||||
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
@ -139,45 +127,14 @@ class ContextBuilder:
|
|||||||
channel=channel or "",
|
channel=channel or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_runtime_context(
|
|
||||||
channel: str | None,
|
|
||||||
chat_id: str | None,
|
|
||||||
timezone: str | None = None,
|
|
||||||
sender_id: str | None = None,
|
|
||||||
supplemental_lines: Sequence[str] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Build untrusted runtime metadata block appended after user content."""
|
|
||||||
lines = [f"Current Time: {current_time_str(timezone)}"]
|
|
||||||
if channel and chat_id:
|
|
||||||
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
|
|
||||||
if sender_id:
|
|
||||||
lines += [f"Sender ID: {sender_id}"]
|
|
||||||
if supplemental_lines:
|
|
||||||
lines.extend(supplemental_lines)
|
|
||||||
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_goal_runtime_guidance(
|
|
||||||
session_metadata: Mapping[str, Any] | None,
|
|
||||||
*,
|
|
||||||
goal_start_requested: bool,
|
|
||||||
) -> str:
|
|
||||||
"""Return turn-scoped goal guidance without changing the system prompt."""
|
|
||||||
goal_active = sustained_goal_active(session_metadata)
|
|
||||||
if not goal_start_requested and not goal_active:
|
|
||||||
return ""
|
|
||||||
return render_template(
|
|
||||||
"agent/goal_runtime.md",
|
|
||||||
strip=True,
|
|
||||||
goal_start_requested=goal_start_requested,
|
|
||||||
goal_active=goal_active,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
|
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
|
||||||
if isinstance(left, str) and isinstance(right, str):
|
if isinstance(left, str) and isinstance(right, str):
|
||||||
return f"{left}\n\n{right}" if left else right
|
if not left:
|
||||||
|
return right
|
||||||
|
if not right:
|
||||||
|
return left
|
||||||
|
return f"{left}\n\n{right}"
|
||||||
|
|
||||||
def _to_blocks(value: Any) -> list[dict[str, Any]]:
|
def _to_blocks(value: Any) -> list[dict[str, Any]]:
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
@ -221,65 +178,17 @@ class ContextBuilder:
|
|||||||
sender_id: str | None = None,
|
sender_id: str | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
session_metadata: Mapping[str, Any] | None = None,
|
session_metadata: Mapping[str, Any] | None = None,
|
||||||
current_runtime_lines: Sequence[str] | None = None,
|
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
runtime_state: Any | None = None,
|
|
||||||
inbound_message: Any | None = None,
|
|
||||||
skip_runtime_lines: bool = False,
|
|
||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
goal_start_requested: bool = False,
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the complete message list for an LLM call."""
|
"""Build the complete message list for an LLM call."""
|
||||||
root = workspace or self.workspace
|
root = workspace or self.workspace
|
||||||
extra = [
|
|
||||||
*goal_state_runtime_lines(session_metadata),
|
|
||||||
]
|
|
||||||
if runtime_state is not None and inbound_message is not None:
|
|
||||||
extra.extend(runtime_lines(runtime_state, inbound_message, root, skip=skip_runtime_lines))
|
|
||||||
if current_runtime_lines:
|
|
||||||
extra.extend(line for line in current_runtime_lines if line)
|
|
||||||
runtime_ctx = self._build_runtime_context(
|
|
||||||
channel,
|
|
||||||
chat_id,
|
|
||||||
self.timezone,
|
|
||||||
sender_id=sender_id,
|
|
||||||
supplemental_lines=extra or None,
|
|
||||||
)
|
|
||||||
user_content = self._build_user_content(current_message, media)
|
user_content = self._build_user_content(current_message, media)
|
||||||
goal_guidance = (
|
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
|
||||||
self._build_goal_runtime_guidance(
|
merged, runtime_context_meta = append_runtime_context(user_content, blocks)
|
||||||
session_metadata,
|
|
||||||
goal_start_requested=goal_start_requested,
|
|
||||||
)
|
|
||||||
if current_role == "user"
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Merge runtime guidance, context, and user content into a single user message
|
|
||||||
# to avoid consecutive same-role messages that some providers reject.
|
|
||||||
# Volatile content is appended to keep the user-content prefix stable for
|
|
||||||
# prompt-cache hits. Goal guidance precedes the metadata-only runtime block.
|
|
||||||
host_parts = [part for part in (goal_guidance, runtime_ctx) if part]
|
|
||||||
host_text_suffix = "\n\n".join(host_parts)
|
|
||||||
if isinstance(user_content, str):
|
|
||||||
merged = "\n\n".join(
|
|
||||||
part for part in (user_content, host_text_suffix) if part
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
merged = list(user_content)
|
|
||||||
if goal_guidance:
|
|
||||||
merged.append({
|
|
||||||
"type": "text",
|
|
||||||
"text": goal_guidance,
|
|
||||||
"_meta": {self._HOST_BLOCK_META_KEY: True},
|
|
||||||
})
|
|
||||||
merged.append({
|
|
||||||
"type": "text",
|
|
||||||
"text": runtime_ctx,
|
|
||||||
"_meta": {self._HOST_BLOCK_META_KEY: True},
|
|
||||||
})
|
|
||||||
messages = [
|
messages = [
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
@ -298,15 +207,15 @@ class ContextBuilder:
|
|||||||
if messages[-1].get("role") == current_role:
|
if messages[-1].get("role") == current_role:
|
||||||
last = dict(messages[-1])
|
last = dict(messages[-1])
|
||||||
last["content"] = self._merge_message_content(last.get("content"), merged)
|
last["content"] = self._merge_message_content(last.get("content"), merged)
|
||||||
if current_role == "user" and isinstance(user_content, str):
|
if current_role == "user" and runtime_context_meta is not None:
|
||||||
internal_meta = dict(last.get("_meta") or {})
|
internal_meta = dict(last.get("_meta") or {})
|
||||||
internal_meta[self._HOST_TEXT_SUFFIX_META_KEY] = host_text_suffix
|
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = runtime_context_meta
|
||||||
last["_meta"] = internal_meta
|
last["_meta"] = internal_meta
|
||||||
messages[-1] = last
|
messages[-1] = last
|
||||||
return messages
|
return messages
|
||||||
current = {"role": current_role, "content": merged}
|
current = {"role": current_role, "content": merged}
|
||||||
if current_role == "user" and isinstance(user_content, str):
|
if current_role == "user" and runtime_context_meta is not None:
|
||||||
current["_meta"] = {self._HOST_TEXT_SUFFIX_META_KEY: host_text_suffix}
|
current["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta}
|
||||||
messages.append(current)
|
messages.append(current)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from dataclasses import dataclass, field
|
|||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@ -27,6 +28,7 @@ from nanobot.agent.memory import Consolidator
|
|||||||
from nanobot.agent.model_runtime import ModelRuntimeResolver
|
from nanobot.agent.model_runtime import ModelRuntimeResolver
|
||||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
|
from nanobot.agent.tools import mcp as mcp_tools
|
||||||
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
|
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
|
||||||
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
@ -52,6 +54,15 @@ from nanobot.command import CommandContext, CommandRouter, register_builtin_comm
|
|||||||
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
|
from nanobot.runtime_context import (
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
|
RUNTIME_CONTEXT_MESSAGE_META,
|
||||||
|
RuntimeContextBlock,
|
||||||
|
RuntimeContextProvider,
|
||||||
|
append_runtime_context,
|
||||||
|
resolve_runtime_context,
|
||||||
|
wrap_runtime_context_lines,
|
||||||
|
)
|
||||||
from nanobot.security.workspace_access import (
|
from nanobot.security.workspace_access import (
|
||||||
WorkspaceScopeResolver,
|
WorkspaceScopeResolver,
|
||||||
bind_workspace_scope,
|
bind_workspace_scope,
|
||||||
@ -60,7 +71,6 @@ from nanobot.security.workspace_access import (
|
|||||||
from nanobot.session import turn_continuation
|
from nanobot.session import turn_continuation
|
||||||
from nanobot.session.automation_turns import automation_history_overrides
|
from nanobot.session.automation_turns import automation_history_overrides
|
||||||
from nanobot.session.goal_state import (
|
from nanobot.session.goal_state import (
|
||||||
explicit_goal_requested,
|
|
||||||
goal_state_runtime_lines,
|
goal_state_runtime_lines,
|
||||||
runner_wall_llm_timeout_s,
|
runner_wall_llm_timeout_s,
|
||||||
sustained_goal_active,
|
sustained_goal_active,
|
||||||
@ -123,6 +133,8 @@ class TurnContext:
|
|||||||
|
|
||||||
history: list[dict[str, Any]] = field(default_factory=list)
|
history: list[dict[str, Any]] = field(default_factory=list)
|
||||||
initial_messages: list[dict[str, Any]] = field(default_factory=list)
|
initial_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
request_context: RequestContext | None = None
|
||||||
|
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
|
||||||
|
|
||||||
final_content: str | None = None
|
final_content: str | None = None
|
||||||
tools_used: list[str] = field(default_factory=list)
|
tools_used: list[str] = field(default_factory=list)
|
||||||
@ -365,6 +377,8 @@ class AgentLoop:
|
|||||||
self._mcp_servers = mcp_servers or {}
|
self._mcp_servers = mcp_servers or {}
|
||||||
self._mcp_stacks: dict[str, MCPConnection] = {}
|
self._mcp_stacks: dict[str, MCPConnection] = {}
|
||||||
self._mcp_connecting = False
|
self._mcp_connecting = False
|
||||||
|
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
||||||
|
self.register_runtime_context_provider(self._provide_mcp_runtime_context)
|
||||||
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
|
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
|
||||||
self._background_tasks: list[asyncio.Task] = []
|
self._background_tasks: list[asyncio.Task] = []
|
||||||
self._session_locks: dict[str, asyncio.Lock] = {}
|
self._session_locks: dict[str, asyncio.Lock] = {}
|
||||||
@ -554,6 +568,14 @@ class AgentLoop:
|
|||||||
"""Connect configured MCP servers."""
|
"""Connect configured MCP servers."""
|
||||||
await agent_context.connect_mcp(self, self.tools)
|
await agent_context.connect_mcp(self, self.tools)
|
||||||
|
|
||||||
|
def register_runtime_context_provider(
|
||||||
|
self,
|
||||||
|
provider: RuntimeContextProvider,
|
||||||
|
) -> None:
|
||||||
|
"""Register a provider resolved once before each inbound model turn."""
|
||||||
|
if provider not in self._runtime_context_providers:
|
||||||
|
self._runtime_context_providers.append(provider)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _runtime_chat_id(msg: InboundMessage) -> str:
|
def _runtime_chat_id(msg: InboundMessage) -> str:
|
||||||
"""Return the chat id shown in runtime metadata for the model."""
|
"""Return the chat id shown in runtime metadata for the model."""
|
||||||
@ -608,6 +630,7 @@ class AgentLoop:
|
|||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
session: Session,
|
session: Session,
|
||||||
|
runtime_context_blocks: list[RuntimeContextBlock] | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Persist the triggering user message before the turn starts.
|
"""Persist the triggering user message before the turn starts.
|
||||||
@ -618,7 +641,7 @@ class AgentLoop:
|
|||||||
return False
|
return False
|
||||||
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
|
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
|
||||||
has_text = isinstance(msg.content, str) and msg.content.strip()
|
has_text = isinstance(msg.content, str) and msg.content.strip()
|
||||||
if has_text or media_paths:
|
if has_text or media_paths or runtime_context_blocks:
|
||||||
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
||||||
extra.update(kwargs)
|
extra.update(kwargs)
|
||||||
text = msg.content if isinstance(msg.content, str) else ""
|
text = msg.content if isinstance(msg.content, str) else ""
|
||||||
@ -626,6 +649,12 @@ class AgentLoop:
|
|||||||
if text_override is not None:
|
if text_override is not None:
|
||||||
text = text_override
|
text = text_override
|
||||||
extra.update(automation_extra)
|
extra.update(automation_extra)
|
||||||
|
text, runtime_context_meta = append_runtime_context(
|
||||||
|
text,
|
||||||
|
runtime_context_blocks or (),
|
||||||
|
)
|
||||||
|
if runtime_context_meta is not None:
|
||||||
|
extra[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
|
||||||
session.add_message("user", text, **extra)
|
session.add_message("user", text, **extra)
|
||||||
self._mark_pending_user_turn(session)
|
self._mark_pending_user_turn(session)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
@ -639,7 +668,7 @@ class AgentLoop:
|
|||||||
history: list[dict[str, Any]],
|
history: list[dict[str, Any]],
|
||||||
pending_summary: str | None,
|
pending_summary: str | None,
|
||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
goal_start_requested: bool = False,
|
runtime_context_blocks: list[RuntimeContextBlock] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the initial message list for the LLM turn."""
|
"""Build the initial message list for the LLM turn."""
|
||||||
scope = self.workspace_scopes.for_message(msg, session.metadata)
|
scope = self.workspace_scopes.for_message(msg, session.metadata)
|
||||||
@ -653,14 +682,53 @@ class AgentLoop:
|
|||||||
session_summary=pending_summary,
|
session_summary=pending_summary,
|
||||||
session_metadata=session.metadata,
|
session_metadata=session.metadata,
|
||||||
workspace=scope.project_path,
|
workspace=scope.project_path,
|
||||||
runtime_state=self,
|
runtime_context_blocks=runtime_context_blocks,
|
||||||
inbound_message=msg,
|
|
||||||
goal_start_requested=goal_start_requested,
|
|
||||||
include_memory_recent_history=include_memory_recent_history,
|
include_memory_recent_history=include_memory_recent_history,
|
||||||
session_key=session.key,
|
session_key=session.key,
|
||||||
unified_session=self._unified_session,
|
unified_session=self._unified_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _provide_mcp_runtime_context(
|
||||||
|
self,
|
||||||
|
request: RequestContext,
|
||||||
|
) -> RuntimeContextBlock | None:
|
||||||
|
lines = mcp_tools.runtime_lines(
|
||||||
|
SimpleNamespace(metadata=request.metadata),
|
||||||
|
configured_server_names=set(self._mcp_servers),
|
||||||
|
connected_server_names=set(self._mcp_stacks),
|
||||||
|
)
|
||||||
|
content = wrap_runtime_context_lines(lines)
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
return RuntimeContextBlock(source="mcp", content=content)
|
||||||
|
|
||||||
|
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
||||||
|
scope = self.workspace_scopes.for_message(ctx.msg, ctx.session.metadata)
|
||||||
|
return RequestContext(
|
||||||
|
channel=ctx.msg.channel,
|
||||||
|
chat_id=ctx.msg.chat_id,
|
||||||
|
message_id=ctx.msg.metadata.get("message_id"),
|
||||||
|
session_key=ctx.session_key,
|
||||||
|
original_user_text=ctx.original_user_text,
|
||||||
|
runtime=ctx.runtime,
|
||||||
|
metadata=dict(ctx.msg.metadata or {}),
|
||||||
|
sender_id=ctx.msg.sender_id,
|
||||||
|
turn_id=ctx.turn_id,
|
||||||
|
workspace=scope.project_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _resolve_runtime_context_for_turn(
|
||||||
|
self,
|
||||||
|
ctx: TurnContext,
|
||||||
|
) -> list[RuntimeContextBlock]:
|
||||||
|
tools = ctx.tools or self.tools
|
||||||
|
providers = [
|
||||||
|
*tools.get_runtime_context_providers(),
|
||||||
|
*self._runtime_context_providers,
|
||||||
|
]
|
||||||
|
assert ctx.request_context is not None
|
||||||
|
return await resolve_runtime_context(providers, ctx.request_context)
|
||||||
|
|
||||||
async def _dispatch_command_inline(
|
async def _dispatch_command_inline(
|
||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
@ -731,6 +799,7 @@ class AgentLoop:
|
|||||||
hook_factories: list[AgentTurnHookFactory] | None = None,
|
hook_factories: list[AgentTurnHookFactory] | None = None,
|
||||||
turn_scopes: list[AbstractContextManager[Any]] | None = None,
|
turn_scopes: list[AbstractContextManager[Any]] | None = None,
|
||||||
tools: ToolRegistry | None = None,
|
tools: ToolRegistry | None = None,
|
||||||
|
request_context: RequestContext | None = None,
|
||||||
) -> tuple[str | None, list[str], list[dict], str, bool]:
|
) -> tuple[str | None, list[str], list[dict], str, bool]:
|
||||||
"""Run the agent iteration loop.
|
"""Run the agent iteration loop.
|
||||||
|
|
||||||
@ -819,7 +888,7 @@ class AgentLoop:
|
|||||||
session_metadata=session.metadata if session is not None else None,
|
session_metadata=session.metadata if session is not None else None,
|
||||||
)
|
)
|
||||||
effective_tools = tools or self.tools
|
effective_tools = tools or self.tools
|
||||||
request_ctx = RequestContext(
|
request_ctx = request_context or RequestContext(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
@ -827,6 +896,7 @@ class AgentLoop:
|
|||||||
original_user_text=original_user_text,
|
original_user_text=original_user_text,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
metadata=dict(metadata or {}),
|
metadata=dict(metadata or {}),
|
||||||
|
workspace=effective_scope.project_path,
|
||||||
)
|
)
|
||||||
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
|
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
|
||||||
request_token = bind_request_context(request_ctx)
|
request_token = bind_request_context(request_ctx)
|
||||||
@ -1259,9 +1329,6 @@ class AgentLoop:
|
|||||||
session_summary=pending,
|
session_summary=pending,
|
||||||
session_metadata=session.metadata,
|
session_metadata=session.metadata,
|
||||||
workspace=workspace_scope.project_path,
|
workspace=workspace_scope.project_path,
|
||||||
runtime_state=self,
|
|
||||||
inbound_message=msg,
|
|
||||||
skip_runtime_lines=is_subagent,
|
|
||||||
session_key=key,
|
session_key=key,
|
||||||
unified_session=self._unified_session,
|
unified_session=self._unified_session,
|
||||||
)
|
)
|
||||||
@ -1561,16 +1628,20 @@ class AgentLoop:
|
|||||||
ctx.runtime,
|
ctx.runtime,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ctx.request_context = self._request_context_for_turn(ctx)
|
||||||
|
ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx)
|
||||||
ctx.initial_messages = self._build_initial_messages(
|
ctx.initial_messages = self._build_initial_messages(
|
||||||
ctx.msg,
|
ctx.msg,
|
||||||
ctx.session,
|
ctx.session,
|
||||||
ctx.history,
|
ctx.history,
|
||||||
ctx.pending_summary,
|
ctx.pending_summary,
|
||||||
include_memory_recent_history=not ctx.ephemeral,
|
include_memory_recent_history=not ctx.ephemeral,
|
||||||
goal_start_requested=explicit_goal_requested(ctx.msg.metadata),
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
)
|
)
|
||||||
ctx.user_persisted_early = self._persist_user_message_early(
|
ctx.user_persisted_early = self._persist_user_message_early(
|
||||||
ctx.msg, ctx.session
|
ctx.msg,
|
||||||
|
ctx.session,
|
||||||
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
)
|
)
|
||||||
|
|
||||||
if ctx.on_progress is None:
|
if ctx.on_progress is None:
|
||||||
@ -1610,6 +1681,7 @@ class AgentLoop:
|
|||||||
hook_factories=ctx.hook_factories,
|
hook_factories=ctx.hook_factories,
|
||||||
turn_scopes=ctx.turn_scopes,
|
turn_scopes=ctx.turn_scopes,
|
||||||
tools=ctx.tools,
|
tools=ctx.tools,
|
||||||
|
request_context=ctx.request_context,
|
||||||
)
|
)
|
||||||
final_content, tools_used, all_msgs, stop_reason, had_injections = result
|
final_content, tools_used, all_msgs, stop_reason, had_injections = result
|
||||||
ctx.final_content = final_content
|
ctx.final_content = final_content
|
||||||
@ -1684,7 +1756,6 @@ class AgentLoop:
|
|||||||
content: list[dict[str, Any]],
|
content: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
should_truncate_text: bool = False,
|
should_truncate_text: bool = False,
|
||||||
drop_runtime: bool = False,
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Strip volatile multimodal payloads before writing session history."""
|
"""Strip volatile multimodal payloads before writing session history."""
|
||||||
filtered: list[dict[str, Any]] = []
|
filtered: list[dict[str, Any]] = []
|
||||||
@ -1693,14 +1764,6 @@ class AgentLoop:
|
|||||||
filtered.append(block)
|
filtered.append(block)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if (
|
|
||||||
drop_runtime
|
|
||||||
and block.get("type") == "text"
|
|
||||||
and isinstance(block.get("_meta"), dict)
|
|
||||||
and block["_meta"].get(ContextBuilder._HOST_BLOCK_META_KEY) is True
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if block.get("type") == "image_url" and block.get("image_url", {}).get(
|
if block.get("type") == "image_url" and block.get("image_url", {}).get(
|
||||||
"url", ""
|
"url", ""
|
||||||
).startswith("data:image/"):
|
).startswith("data:image/"):
|
||||||
@ -1741,8 +1804,8 @@ class AgentLoop:
|
|||||||
for m in messages[skip:]:
|
for m in messages[skip:]:
|
||||||
entry = dict(m)
|
entry = dict(m)
|
||||||
internal_meta = entry.pop("_meta", None)
|
internal_meta = entry.pop("_meta", None)
|
||||||
host_text_suffix = (
|
runtime_context_meta = (
|
||||||
internal_meta.get(ContextBuilder._HOST_TEXT_SUFFIX_META_KEY)
|
internal_meta.get(RUNTIME_CONTEXT_MESSAGE_META)
|
||||||
if isinstance(internal_meta, dict)
|
if isinstance(internal_meta, dict)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@ -1770,24 +1833,13 @@ class AgentLoop:
|
|||||||
]
|
]
|
||||||
entry["content"] = filtered
|
entry["content"] = filtered
|
||||||
elif role == "user":
|
elif role == "user":
|
||||||
if (
|
|
||||||
isinstance(content, str)
|
|
||||||
and isinstance(host_text_suffix, str)
|
|
||||||
and host_text_suffix
|
|
||||||
and content.endswith(host_text_suffix)
|
|
||||||
):
|
|
||||||
before = content[: -len(host_text_suffix)]
|
|
||||||
if before.endswith("\n\n"):
|
|
||||||
before = before[:-2]
|
|
||||||
if before:
|
|
||||||
entry["content"] = before
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
filtered = self._sanitize_persisted_blocks(content, drop_runtime=True)
|
filtered = self._sanitize_persisted_blocks(content)
|
||||||
if not filtered:
|
if not filtered:
|
||||||
continue
|
continue
|
||||||
entry["content"] = filtered
|
entry["content"] = filtered
|
||||||
|
if isinstance(runtime_context_meta, dict):
|
||||||
|
entry[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
|
||||||
entry.setdefault("timestamp", datetime.now().isoformat())
|
entry.setdefault("timestamp", datetime.now().isoformat())
|
||||||
session.messages.append(entry)
|
session.messages.append(entry)
|
||||||
if role == "assistant":
|
if role == "assistant":
|
||||||
|
|||||||
@ -433,10 +433,8 @@ class SubagentManager:
|
|||||||
|
|
||||||
def _build_subagent_prompt(self, workspace: Path | None = None) -> str:
|
def _build_subagent_prompt(self, workspace: Path | None = None) -> str:
|
||||||
"""Build a focused system prompt for the subagent."""
|
"""Build a focused system prompt for the subagent."""
|
||||||
from nanobot.agent.context import ContextBuilder
|
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
|
||||||
time_ctx = ContextBuilder._build_runtime_context(None, None)
|
|
||||||
root = workspace or self.workspace
|
root = workspace or self.workspace
|
||||||
skills_summary = SkillsLoader(
|
skills_summary = SkillsLoader(
|
||||||
root,
|
root,
|
||||||
@ -444,7 +442,6 @@ class SubagentManager:
|
|||||||
).build_skills_summary()
|
).build_skills_summary()
|
||||||
return render_template(
|
return render_template(
|
||||||
"agent/subagent_system.md",
|
"agent/subagent_system.md",
|
||||||
time_ctx=time_ctx,
|
|
||||||
workspace=str(root),
|
workspace=str(root),
|
||||||
skills_summary=skills_summary or "",
|
skills_summary=skills_summary or "",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ if typing.TYPE_CHECKING:
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from nanobot.agent.tools.context import ToolContext
|
from nanobot.agent.tools.context import ToolContext
|
||||||
|
from nanobot.runtime_context import RuntimeContextProvider
|
||||||
|
|
||||||
_ToolT = TypeVar("_ToolT", bound="Tool")
|
_ToolT = TypeVar("_ToolT", bound="Tool")
|
||||||
|
|
||||||
@ -206,6 +207,10 @@ class Tool(ABC):
|
|||||||
def create(cls, ctx: ToolContext) -> Tool:
|
def create(cls, ctx: ToolContext) -> Tool:
|
||||||
return cls()
|
return cls()
|
||||||
|
|
||||||
|
def runtime_context_provider(self) -> RuntimeContextProvider | None:
|
||||||
|
"""Return optional per-turn prompt context owned by this tool."""
|
||||||
|
return None
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def execute(self, **kwargs: Any) -> Any:
|
async def execute(self, **kwargs: Any) -> Any:
|
||||||
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
|
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from typing import Any
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
|
from nanobot.agent.tools.context import RequestContext
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
ArraySchema,
|
ArraySchema,
|
||||||
BooleanSchema,
|
BooleanSchema,
|
||||||
@ -16,7 +17,9 @@ from nanobot.agent.tools.schema import (
|
|||||||
tool_parameters_schema,
|
tool_parameters_schema,
|
||||||
)
|
)
|
||||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
|
from nanobot.apps.cli.utils import runtime_lines_for_request
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
|
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
|
||||||
from nanobot.security.workspace_access import current_tool_workspace
|
from nanobot.security.workspace_access import current_tool_workspace
|
||||||
|
|
||||||
|
|
||||||
@ -112,6 +115,23 @@ class CliAppsTool(Tool):
|
|||||||
+ installed_note
|
+ installed_note
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def runtime_context_provider(self):
|
||||||
|
return self._provide_runtime_context
|
||||||
|
|
||||||
|
async def _provide_runtime_context(
|
||||||
|
self,
|
||||||
|
request: RequestContext,
|
||||||
|
) -> RuntimeContextBlock | None:
|
||||||
|
lines = runtime_lines_for_request(
|
||||||
|
request.original_user_text or "",
|
||||||
|
request.metadata,
|
||||||
|
request.workspace or self.workspace,
|
||||||
|
)
|
||||||
|
content = wrap_runtime_context_lines(lines)
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
return RuntimeContextBlock(source="cli_apps", content=content)
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar, Token
|
from contextvars import ContextVar, Token
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
|
from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -25,6 +26,9 @@ class RequestContext:
|
|||||||
original_user_text: str | None = None
|
original_user_text: str | None = None
|
||||||
runtime: LLMRuntime | None = None
|
runtime: LLMRuntime | None = None
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
sender_id: str | None = None
|
||||||
|
turn_id: str | None = None
|
||||||
|
workspace: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
|
|||||||
@ -138,6 +138,9 @@ class _LegacyErrorPrefixTool(Tool):
|
|||||||
def parameters(self) -> dict[str, Any]:
|
def parameters(self) -> dict[str, Any]:
|
||||||
return self._wrapped.parameters
|
return self._wrapped.parameters
|
||||||
|
|
||||||
|
def runtime_context_provider(self):
|
||||||
|
return self._wrapped.runtime_context_provider()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def read_only(self) -> bool:
|
def read_only(self) -> bool:
|
||||||
return self._wrapped.read_only
|
return self._wrapped.read_only
|
||||||
|
|||||||
@ -11,17 +11,22 @@ from nanobot.agent.goal_permission import (
|
|||||||
revoke_goal_mutation_permission,
|
revoke_goal_mutation_permission,
|
||||||
)
|
)
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
||||||
from nanobot.agent.tools.context import current_request_context
|
from nanobot.agent.tools.context import RequestContext, current_request_context
|
||||||
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
||||||
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
||||||
|
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
|
||||||
from nanobot.session.goal_state import (
|
from nanobot.session.goal_state import (
|
||||||
GOAL_STATE_KEY,
|
GOAL_STATE_KEY,
|
||||||
MAX_GOAL_OBJECTIVE_CHARS,
|
MAX_GOAL_OBJECTIVE_CHARS,
|
||||||
discard_legacy_goal_state_key,
|
discard_legacy_goal_state_key,
|
||||||
|
explicit_goal_requested,
|
||||||
goal_state_raw,
|
goal_state_raw,
|
||||||
|
goal_state_runtime_lines,
|
||||||
parse_goal_state,
|
parse_goal_state,
|
||||||
|
sustained_goal_active,
|
||||||
)
|
)
|
||||||
from nanobot.session.turn_continuation import reset_goal_continuation_rounds
|
from nanobot.session.turn_continuation import reset_goal_continuation_rounds
|
||||||
|
from nanobot.utils.prompt_templates import render_template
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
@ -158,6 +163,31 @@ class CreateGoalTool(Tool, _GoalToolsMixin):
|
|||||||
"completion criteria. Do not retry after a successful creation."
|
"completion criteria. Do not retry after a successful creation."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def runtime_context_provider(self):
|
||||||
|
return self._provide_runtime_context
|
||||||
|
|
||||||
|
async def _provide_runtime_context(
|
||||||
|
self,
|
||||||
|
request: RequestContext,
|
||||||
|
) -> RuntimeContextBlock | None:
|
||||||
|
if not request.session_key:
|
||||||
|
return None
|
||||||
|
session = self._sessions.get_or_create(request.session_key)
|
||||||
|
goal_start_requested = explicit_goal_requested(request.metadata)
|
||||||
|
goal_active = sustained_goal_active(session.metadata)
|
||||||
|
if not goal_start_requested and not goal_active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
guidance = render_template(
|
||||||
|
"agent/goal_runtime.md",
|
||||||
|
strip=True,
|
||||||
|
goal_start_requested=goal_start_requested,
|
||||||
|
goal_active=goal_active,
|
||||||
|
)
|
||||||
|
state = wrap_runtime_context_lines(goal_state_runtime_lines(session.metadata))
|
||||||
|
content = "\n\n".join(part for part in (guidance, state) if part)
|
||||||
|
return RuntimeContextBlock(source="goal", content=content)
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
objective: str,
|
objective: str,
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
"""Tool registry for dynamic tool management."""
|
"""Tool registry for dynamic tool management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool, ToolResult
|
||||||
from nanobot.agent.tools.context import ContextAware, current_request_context
|
from nanobot.agent.tools.context import ContextAware, current_request_context
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.runtime_context import RuntimeContextProvider
|
||||||
|
|
||||||
|
|
||||||
def is_tool_error_result(name: str, result: Any) -> bool:
|
def is_tool_error_result(name: str, result: Any) -> bool:
|
||||||
return isinstance(result, ToolResult) and result.is_error
|
return isinstance(result, ToolResult) and result.is_error
|
||||||
@ -36,6 +41,15 @@ class ToolRegistry:
|
|||||||
"""Get a tool by name."""
|
"""Get a tool by name."""
|
||||||
return self._tools.get(name)
|
return self._tools.get(name)
|
||||||
|
|
||||||
|
def get_runtime_context_providers(self) -> list[RuntimeContextProvider]:
|
||||||
|
"""Return tool-owned providers in stable tool-name order."""
|
||||||
|
providers: list[RuntimeContextProvider] = []
|
||||||
|
for name in sorted(self._tools):
|
||||||
|
provider = self._tools[name].runtime_context_provider()
|
||||||
|
if provider is not None:
|
||||||
|
providers.append(provider)
|
||||||
|
return providers
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _lookup_key(name: str) -> str:
|
def _lookup_key(name: str) -> str:
|
||||||
"""Normalize names for suggestions only; never for execution."""
|
"""Normalize names for suggestions only; never for execution."""
|
||||||
|
|||||||
@ -18,14 +18,15 @@ def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[
|
|||||||
return []
|
return []
|
||||||
text = message.content if isinstance(getattr(message, "content", None), str) else ""
|
text = message.content if isinstance(getattr(message, "content", None), str) else ""
|
||||||
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
|
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
|
||||||
return _cli_app_runtime_lines(text, metadata, workspace)
|
return runtime_lines_for_request(text, metadata, workspace)
|
||||||
|
|
||||||
|
|
||||||
def _cli_app_runtime_lines(
|
def runtime_lines_for_request(
|
||||||
text: str,
|
text: str,
|
||||||
metadata: Mapping[str, Any] | None,
|
metadata: Mapping[str, Any] | None,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
"""Return CLI App annotations from an immutable request snapshot."""
|
||||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||||
if isinstance(structured, list):
|
if isinstance(structured, list):
|
||||||
mentions = [
|
mentions = [
|
||||||
|
|||||||
@ -747,7 +747,7 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
session = ctx.session or ctx.loop.sessions.get_or_create(ctx.key)
|
session = ctx.session or ctx.loop.sessions.get_or_create(ctx.key)
|
||||||
history = session.get_history(max_messages=0)
|
history = session.get_history(max_messages=0, include_runtime_context=False)
|
||||||
visible = [_format_history_message(m) for m in history]
|
visible = [_format_history_message(m) for m in history]
|
||||||
visible = [m for m in visible if m is not None]
|
visible = [m for m in visible if m is not None]
|
||||||
recent = visible[-count:]
|
recent = visible[-count:]
|
||||||
|
|||||||
126
nanobot/runtime_context.py
Normal file
126
nanobot/runtime_context.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
"""Optional, persistent context appended to the current user prompt."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any, TypeAlias
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.tools.context import RequestContext
|
||||||
|
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META = "_runtime_context"
|
||||||
|
RUNTIME_CONTEXT_MESSAGE_META = "runtime_context"
|
||||||
|
RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
|
||||||
|
RUNTIME_CONTEXT_END = "[/Runtime Context]"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeContextBlock:
|
||||||
|
"""One provider-owned block appended to the current user content."""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
RuntimeContextResult: TypeAlias = (
|
||||||
|
RuntimeContextBlock | Sequence[RuntimeContextBlock] | None
|
||||||
|
)
|
||||||
|
RuntimeContextProvider: TypeAlias = Callable[
|
||||||
|
["RequestContext"], Awaitable[RuntimeContextResult]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_runtime_context_lines(lines: Iterable[str]) -> str:
|
||||||
|
"""Wrap non-empty runtime metadata lines in the established prompt markers."""
|
||||||
|
content = "\n".join(line for line in lines if line)
|
||||||
|
if not content:
|
||||||
|
return ""
|
||||||
|
return f"{RUNTIME_CONTEXT_TAG}\n{content}\n{RUNTIME_CONTEXT_END}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_runtime_context_blocks(result: RuntimeContextResult) -> list[RuntimeContextBlock]:
|
||||||
|
"""Return validated, non-empty blocks while preserving provider order."""
|
||||||
|
if result is None:
|
||||||
|
return []
|
||||||
|
values = [result] if isinstance(result, RuntimeContextBlock) else list(result)
|
||||||
|
blocks: list[RuntimeContextBlock] = []
|
||||||
|
for block in values:
|
||||||
|
if not isinstance(block, RuntimeContextBlock):
|
||||||
|
raise TypeError("runtime context providers must return RuntimeContextBlock values")
|
||||||
|
source = block.source.strip()
|
||||||
|
content = block.content.strip()
|
||||||
|
if not source:
|
||||||
|
raise ValueError("runtime context block source must not be empty")
|
||||||
|
if content:
|
||||||
|
blocks.append(RuntimeContextBlock(source=source, content=content))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_runtime_context(
|
||||||
|
providers: Iterable[RuntimeContextProvider],
|
||||||
|
request: RequestContext,
|
||||||
|
) -> list[RuntimeContextBlock]:
|
||||||
|
"""Resolve providers once, sequentially, in the caller's stable order."""
|
||||||
|
blocks: list[RuntimeContextBlock] = []
|
||||||
|
for provider in providers:
|
||||||
|
blocks.extend(normalize_runtime_context_blocks(await provider(request)))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def append_runtime_context(
|
||||||
|
content: Any,
|
||||||
|
blocks: Sequence[RuntimeContextBlock],
|
||||||
|
) -> tuple[Any, dict[str, Any] | None]:
|
||||||
|
"""Append blocks and return a durable marker for exact display-time removal."""
|
||||||
|
if not blocks:
|
||||||
|
return content, None
|
||||||
|
|
||||||
|
rendered = [block.content for block in blocks]
|
||||||
|
sources = [block.source for block in blocks]
|
||||||
|
if isinstance(content, list):
|
||||||
|
context_blocks = [{"type": "text", "text": text} for text in rendered]
|
||||||
|
return [*content, *context_blocks], {
|
||||||
|
"version": 1,
|
||||||
|
"sources": sources,
|
||||||
|
"blocks": context_blocks,
|
||||||
|
}
|
||||||
|
|
||||||
|
text = "" if content is None else str(content)
|
||||||
|
suffix = "\n\n".join(rendered)
|
||||||
|
merged = f"{text}\n\n{suffix}" if text else suffix
|
||||||
|
return merged, {
|
||||||
|
"version": 1,
|
||||||
|
"sources": sources,
|
||||||
|
"suffix": suffix,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def public_history_message(message: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return a user-visible copy with trusted runtime context removed exactly."""
|
||||||
|
cleaned = deepcopy(dict(message))
|
||||||
|
marker = cleaned.pop(RUNTIME_CONTEXT_HISTORY_META, None)
|
||||||
|
if not isinstance(marker, Mapping) or marker.get("version") != 1:
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
content = cleaned.get("content")
|
||||||
|
suffix = marker.get("suffix")
|
||||||
|
if isinstance(content, str) and isinstance(suffix, str) and suffix:
|
||||||
|
if content == suffix:
|
||||||
|
cleaned["content"] = ""
|
||||||
|
elif content.endswith("\n\n" + suffix):
|
||||||
|
cleaned["content"] = content[: -(len(suffix) + 2)]
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
expected = marker.get("blocks")
|
||||||
|
if isinstance(content, list) and isinstance(expected, list) and expected:
|
||||||
|
count = len(expected)
|
||||||
|
if content[-count:] == expected:
|
||||||
|
cleaned["content"] = content[:-count]
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def public_history_messages(messages: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Return user-visible copies of persisted messages."""
|
||||||
|
return [public_history_message(message) for message in messages]
|
||||||
@ -7,6 +7,7 @@ from copy import deepcopy
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from nanobot.runtime_context import RUNTIME_CONTEXT_HISTORY_META
|
||||||
from nanobot.sdk.types import (
|
from nanobot.sdk.types import (
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SessionSnapshot,
|
SessionSnapshot,
|
||||||
@ -22,7 +23,7 @@ if TYPE_CHECKING:
|
|||||||
class SessionClient:
|
class SessionClient:
|
||||||
"""Session management helpers exposed through ``bot.sessions``."""
|
"""Session management helpers exposed through ``bot.sessions``."""
|
||||||
|
|
||||||
_RESERVED_MESSAGE_KEYS = {"role", "content"}
|
_RESERVED_MESSAGE_KEYS = {"role", "content", RUNTIME_CONTEXT_HISTORY_META}
|
||||||
_VALID_ROLES = {"user", "assistant", "tool", "system"}
|
_VALID_ROLES = {"user", "assistant", "tool", "system"}
|
||||||
|
|
||||||
def __init__(self, loop: AgentLoop) -> None:
|
def __init__(self, loop: AgentLoop) -> None:
|
||||||
|
|||||||
@ -6,6 +6,8 @@ from copy import deepcopy
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal, Mapping, TypeAlias
|
from typing import Any, Literal, Mapping, TypeAlias
|
||||||
|
|
||||||
|
from nanobot.runtime_context import public_history_messages
|
||||||
|
|
||||||
StreamEventType: TypeAlias = Literal[
|
StreamEventType: TypeAlias = Literal[
|
||||||
"run.started",
|
"run.started",
|
||||||
"text.delta",
|
"text.delta",
|
||||||
@ -125,7 +127,7 @@ def snapshot_from_session(session: Any) -> SessionSnapshot:
|
|||||||
created_at=session.created_at.isoformat(),
|
created_at=session.created_at.isoformat(),
|
||||||
updated_at=session.updated_at.isoformat(),
|
updated_at=session.updated_at.isoformat(),
|
||||||
metadata=deepcopy(session.metadata),
|
metadata=deepcopy(session.metadata),
|
||||||
messages=deepcopy(session.messages),
|
messages=public_history_messages(session.messages),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -135,7 +137,11 @@ def snapshot_from_payload(payload: Mapping[str, Any]) -> SessionSnapshot:
|
|||||||
created_at=payload.get("created_at"),
|
created_at=payload.get("created_at"),
|
||||||
updated_at=payload.get("updated_at"),
|
updated_at=payload.get("updated_at"),
|
||||||
metadata=deepcopy(dict(payload.get("metadata") or {})),
|
metadata=deepcopy(dict(payload.get("metadata") or {})),
|
||||||
messages=deepcopy(list(payload.get("messages") or [])),
|
messages=public_history_messages(
|
||||||
|
message
|
||||||
|
for message in list(payload.get("messages") or [])
|
||||||
|
if isinstance(message, Mapping)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,10 @@ from typing import Any
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.paths import get_legacy_sessions_dir
|
from nanobot.config.paths import get_legacy_sessions_dir
|
||||||
|
from nanobot.runtime_context import (
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
|
public_history_message,
|
||||||
|
)
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
ensure_dir,
|
ensure_dir,
|
||||||
estimate_message_tokens,
|
estimate_message_tokens,
|
||||||
@ -93,6 +97,7 @@ def _text_preview(content: Any) -> str:
|
|||||||
|
|
||||||
def _message_preview_text(message: dict[str, Any]) -> str:
|
def _message_preview_text(message: dict[str, Any]) -> str:
|
||||||
"""Session list preview text; subagent inject blobs are shortened for display."""
|
"""Session list preview text; subagent inject blobs are shortened for display."""
|
||||||
|
message = public_history_message(message)
|
||||||
content: Any = message.get("content")
|
content: Any = message.get("content")
|
||||||
if message.get("injected_event") == "subagent_result" and isinstance(content, str):
|
if message.get("injected_event") == "subagent_result" and isinstance(content, str):
|
||||||
content = scrub_subagent_announce_body(content)
|
content = scrub_subagent_announce_body(content)
|
||||||
@ -153,6 +158,7 @@ class Session:
|
|||||||
*,
|
*,
|
||||||
max_tokens: int = 0,
|
max_tokens: int = 0,
|
||||||
extend_to_user: bool = False,
|
extend_to_user: bool = False,
|
||||||
|
include_runtime_context: bool = True,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return unconsolidated messages for LLM input.
|
"""Return unconsolidated messages for LLM input.
|
||||||
|
|
||||||
@ -187,6 +193,12 @@ class Session:
|
|||||||
for message in sliced:
|
for message in sliced:
|
||||||
if message.get("_command"):
|
if message.get("_command"):
|
||||||
continue
|
continue
|
||||||
|
has_persisted_runtime_context = isinstance(
|
||||||
|
message.get(RUNTIME_CONTEXT_HISTORY_META),
|
||||||
|
dict,
|
||||||
|
)
|
||||||
|
if not include_runtime_context:
|
||||||
|
message = public_history_message(message)
|
||||||
content = message.get("content", "")
|
content = message.get("content", "")
|
||||||
role = message.get("role")
|
role = message.get("role")
|
||||||
if role == "assistant" and isinstance(content, str):
|
if role == "assistant" and isinstance(content, str):
|
||||||
@ -203,7 +215,13 @@ class Session:
|
|||||||
)
|
)
|
||||||
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
||||||
cli_apps = message.get("cli_apps")
|
cli_apps = message.get("cli_apps")
|
||||||
if role == "user" and isinstance(cli_apps, list) and cli_apps and isinstance(content, str):
|
if (
|
||||||
|
not has_persisted_runtime_context
|
||||||
|
and role == "user"
|
||||||
|
and isinstance(cli_apps, list)
|
||||||
|
and cli_apps
|
||||||
|
and isinstance(content, str)
|
||||||
|
):
|
||||||
cli_lines: list[str] = []
|
cli_lines: list[str] = []
|
||||||
for item in cli_apps[:8]:
|
for item in cli_apps[:8]:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
@ -221,7 +239,8 @@ class Session:
|
|||||||
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
||||||
mcp_presets = message.get("mcp_presets")
|
mcp_presets = message.get("mcp_presets")
|
||||||
if (
|
if (
|
||||||
role == "user"
|
not has_persisted_runtime_context
|
||||||
|
and role == "user"
|
||||||
and isinstance(mcp_presets, list)
|
and isinstance(mcp_presets, list)
|
||||||
and mcp_presets
|
and mcp_presets
|
||||||
and isinstance(content, str)
|
and isinstance(content, str)
|
||||||
|
|||||||
@ -31,6 +31,7 @@ from nanobot.bus.runtime_events import (
|
|||||||
TurnRunStatusChanged,
|
TurnRunStatusChanged,
|
||||||
)
|
)
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
|
from nanobot.runtime_context import public_history_message
|
||||||
from nanobot.session.goal_state import goal_state_ws_blob
|
from nanobot.session.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.history_visibility import is_hidden_history_message
|
from nanobot.session.history_visibility import is_hidden_history_message
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
@ -79,6 +80,7 @@ def _title_inputs(session: Session) -> tuple[str, str]:
|
|||||||
continue
|
continue
|
||||||
if is_hidden_history_message(message):
|
if is_hidden_history_message(message):
|
||||||
continue
|
continue
|
||||||
|
message = public_history_message(message)
|
||||||
role = message.get("role")
|
role = message.get("role")
|
||||||
content = message.get("content")
|
content = message.get("content")
|
||||||
if not isinstance(content, str) or not content.strip():
|
if not isinstance(content, str) or not content.strip():
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
# Subagent
|
# Subagent
|
||||||
|
|
||||||
{{ time_ctx }}
|
|
||||||
|
|
||||||
You are a subagent spawned by the main agent to complete a specific task.
|
You are a subagent spawned by the main agent to complete a specific task.
|
||||||
Stay focused on the assigned task. Your final response will be reported back to the main agent.
|
Stay focused on the assigned task. Your final response will be reported back to the main agent.
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ from urllib.parse import unquote, urlparse
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.paths import get_webui_dir
|
from nanobot.config.paths import get_webui_dir
|
||||||
|
from nanobot.runtime_context import public_history_message
|
||||||
from nanobot.session.automation_turns import is_automation_kind
|
from nanobot.session.automation_turns import is_automation_kind
|
||||||
from nanobot.session.history_visibility import is_hidden_history_message
|
from nanobot.session.history_visibility import is_hidden_history_message
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
@ -785,6 +786,7 @@ def write_session_messages_as_transcript(
|
|||||||
for msg in messages:
|
for msg in messages:
|
||||||
if is_hidden_history_message(msg):
|
if is_hidden_history_message(msg):
|
||||||
continue
|
continue
|
||||||
|
msg = public_history_message(msg)
|
||||||
role = msg.get("role")
|
role = msg.get("role")
|
||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
text = content if isinstance(content, str) else ""
|
text = content if isinstance(content, str) else ""
|
||||||
@ -876,6 +878,7 @@ def _session_user_event(
|
|||||||
return None
|
return None
|
||||||
if is_hidden_history_message(message):
|
if is_hidden_history_message(message):
|
||||||
return None
|
return None
|
||||||
|
message = public_history_message(message)
|
||||||
if _is_legacy_raw_subagent_result(message):
|
if _is_legacy_raw_subagent_result(message):
|
||||||
return None
|
return None
|
||||||
content = message.get("content")
|
content = message.get("content")
|
||||||
|
|||||||
@ -26,6 +26,7 @@ from websockets.http11 import Response
|
|||||||
from nanobot.command.builtin import builtin_command_palette
|
from nanobot.command.builtin import builtin_command_palette
|
||||||
from nanobot.cron.session_turns import is_bound_cron_job
|
from nanobot.cron.session_turns import is_bound_cron_job
|
||||||
from nanobot.cron.types import CronJob, CronSchedule
|
from nanobot.cron.types import CronJob, CronSchedule
|
||||||
|
from nanobot.runtime_context import public_history_messages
|
||||||
from nanobot.triggers.local_types import LocalTrigger
|
from nanobot.triggers.local_types import LocalTrigger
|
||||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||||
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
|
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
|
||||||
@ -426,6 +427,9 @@ class GatewayHTTPHandler:
|
|||||||
messages = data.get("messages")
|
messages = data.get("messages")
|
||||||
if isinstance(messages, list):
|
if isinstance(messages, list):
|
||||||
scrub_subagent_messages_for_channel(messages)
|
scrub_subagent_messages_for_channel(messages)
|
||||||
|
data["messages"] = public_history_messages(
|
||||||
|
message for message in messages if isinstance(message, dict)
|
||||||
|
)
|
||||||
self.media.augment_media_urls(data)
|
self.media.augment_media_urls(data)
|
||||||
return _http_json_response(data)
|
return _http_json_response(data)
|
||||||
|
|
||||||
|
|||||||
@ -5,8 +5,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.runtime_context import RuntimeContextBlock
|
||||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@ -16,42 +15,6 @@ def _builder(tmp_path: Path, **kw) -> ContextBuilder:
|
|||||||
return ContextBuilder(workspace=tmp_path, **kw)
|
return ContextBuilder(workspace=tmp_path, **kw)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _build_runtime_context (static)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildRuntimeContext:
|
|
||||||
def test_time_only(self):
|
|
||||||
ctx = ContextBuilder._build_runtime_context(None, None)
|
|
||||||
assert "[Runtime Context" in ctx
|
|
||||||
assert "[/Runtime Context]" in ctx
|
|
||||||
assert "Current Time:" in ctx
|
|
||||||
assert "Channel:" not in ctx
|
|
||||||
|
|
||||||
def test_with_channel_and_chat_id(self):
|
|
||||||
ctx = ContextBuilder._build_runtime_context("telegram", "chat123")
|
|
||||||
assert "Channel: telegram" in ctx
|
|
||||||
assert "Chat ID: chat123" in ctx
|
|
||||||
|
|
||||||
def test_with_sender_id(self):
|
|
||||||
ctx = ContextBuilder._build_runtime_context("cli", "direct", sender_id="user1")
|
|
||||||
assert "Sender ID: user1" in ctx
|
|
||||||
|
|
||||||
def test_with_timezone(self):
|
|
||||||
ctx = ContextBuilder._build_runtime_context(None, None, timezone="Asia/Shanghai")
|
|
||||||
assert "Current Time:" in ctx
|
|
||||||
|
|
||||||
def test_no_channel_no_chat_id_omits_both(self):
|
|
||||||
ctx = ContextBuilder._build_runtime_context(None, None)
|
|
||||||
assert "Channel:" not in ctx
|
|
||||||
assert "Chat ID:" not in ctx
|
|
||||||
|
|
||||||
def test_no_sender_id_omits(self):
|
|
||||||
ctx = ContextBuilder._build_runtime_context("cli", "direct")
|
|
||||||
assert "Sender ID:" not in ctx
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _merge_message_content (static)
|
# _merge_message_content (static)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -315,118 +278,46 @@ class TestBuildMessages:
|
|||||||
assert messages[1]["role"] == "user"
|
assert messages[1]["role"] == "user"
|
||||||
assert "hello" in str(messages[1]["content"])
|
assert "hello" in str(messages[1]["content"])
|
||||||
|
|
||||||
def test_runtime_context_injected(self, tmp_path):
|
def test_runtime_context_is_not_injected_by_default(self, tmp_path):
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
messages = builder.build_messages([], "hello", channel="cli", chat_id="direct")
|
messages = builder.build_messages([], "hello", channel="cli", chat_id="direct")
|
||||||
user_msg = str(messages[-1]["content"])
|
user_msg = str(messages[-1]["content"])
|
||||||
assert "[Runtime Context" in user_msg
|
assert user_msg == "hello"
|
||||||
assert "hello" in user_msg
|
assert "Runtime Context" not in user_msg
|
||||||
|
assert "Current Time:" not in user_msg
|
||||||
|
assert "Chat ID:" not in user_msg
|
||||||
|
|
||||||
def test_session_metadata_injects_active_goal_state(self, tmp_path):
|
def test_session_metadata_does_not_inject_context_without_provider(self, tmp_path):
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
meta = {
|
|
||||||
GOAL_STATE_KEY: {"status": "active", "objective": "Finish docs migration."},
|
|
||||||
}
|
|
||||||
messages = builder.build_messages(
|
messages = builder.build_messages(
|
||||||
[],
|
[],
|
||||||
"hi",
|
"hi",
|
||||||
channel="cli",
|
channel="cli",
|
||||||
chat_id="x",
|
chat_id="x",
|
||||||
session_metadata=meta,
|
session_metadata={"goal_state": {"status": "active", "objective": "hidden"}},
|
||||||
)
|
)
|
||||||
user_msg = str(messages[-1]["content"])
|
assert messages[-1]["content"] == "hi"
|
||||||
assert ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG in user_msg
|
|
||||||
assert "Execute sustained work" in user_msg
|
|
||||||
assert "Start or replace the sustained goal" not in user_msg
|
|
||||||
assert "Goal (active):" in user_msg
|
|
||||||
assert "Finish docs migration." in user_msg
|
|
||||||
|
|
||||||
def test_goal_start_turn_injects_objective_guidance_after_user_text(self, tmp_path):
|
def test_explicit_runtime_context_blocks_are_appended(self, tmp_path):
|
||||||
builder = _builder(tmp_path)
|
|
||||||
normal_messages = builder.build_messages([], "hi", channel="cli", chat_id="direct")
|
|
||||||
messages = builder.build_messages(
|
|
||||||
[],
|
|
||||||
"/goal audit the repo",
|
|
||||||
channel="cli",
|
|
||||||
chat_id="direct",
|
|
||||||
goal_start_requested=True,
|
|
||||||
)
|
|
||||||
stale_messages = builder.build_messages(
|
|
||||||
[],
|
|
||||||
"/goal stale request",
|
|
||||||
channel="cli",
|
|
||||||
chat_id="direct",
|
|
||||||
inbound_message=InboundMessage(
|
|
||||||
channel="cli",
|
|
||||||
sender_id="system",
|
|
||||||
chat_id="direct",
|
|
||||||
content="/goal stale request",
|
|
||||||
metadata={"original_command": "/goal", "goal_requested": True},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
user_msg = str(messages[-1]["content"])
|
|
||||||
assert "Write a durable objective" in user_msg
|
|
||||||
assert "complete `/goal <task>` command" in user_msg
|
|
||||||
guidance = user_msg[
|
|
||||||
user_msg.index(ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG) :
|
|
||||||
user_msg.index(ContextBuilder._GOAL_RUNTIME_GUIDANCE_END)
|
|
||||||
].lower()
|
|
||||||
assert "authorization" not in guidance
|
|
||||||
assert "host-issued" not in guidance
|
|
||||||
assert user_msg.index("/goal audit the repo") < user_msg.index(
|
|
||||||
ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG
|
|
||||||
)
|
|
||||||
assert user_msg.index(ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG) < user_msg.index(
|
|
||||||
ContextBuilder._RUNTIME_CONTEXT_TAG
|
|
||||||
)
|
|
||||||
assert normal_messages[0]["content"] == messages[0]["content"]
|
|
||||||
assert ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG not in str(
|
|
||||||
normal_messages[-1]["content"]
|
|
||||||
)
|
|
||||||
assert ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG not in str(
|
|
||||||
stale_messages[-1]["content"]
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_goal_state_does_not_leak_without_session_metadata(self, tmp_path):
|
|
||||||
builder = _builder(tmp_path)
|
|
||||||
other_session_meta = {
|
|
||||||
GOAL_STATE_KEY: {"status": "active", "objective": "Other chat goal."},
|
|
||||||
}
|
|
||||||
|
|
||||||
with_goal = builder.build_messages(
|
|
||||||
[],
|
|
||||||
"hi",
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-a",
|
|
||||||
session_metadata=other_session_meta,
|
|
||||||
)
|
|
||||||
without_goal = builder.build_messages(
|
|
||||||
[],
|
|
||||||
"hi",
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-b",
|
|
||||||
session_metadata={},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "Other chat goal." in str(with_goal[-1]["content"])
|
|
||||||
assert "Other chat goal." not in str(without_goal[-1]["content"])
|
|
||||||
assert "Goal (active):" not in str(without_goal[-1]["content"])
|
|
||||||
|
|
||||||
def test_current_runtime_lines_are_injected(self, tmp_path):
|
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
messages = builder.build_messages(
|
messages = builder.build_messages(
|
||||||
[],
|
[],
|
||||||
"please use @zoom tonight",
|
"please use @zoom tonight",
|
||||||
current_runtime_lines=[
|
runtime_context_blocks=[
|
||||||
"CLI App Attachment: @zoom (installed; tool=run_cli_app; entry_point=cli-anything-zoom).",
|
RuntimeContextBlock(
|
||||||
|
source="cli_apps",
|
||||||
|
content="CLI App Attachment: @zoom (installed; tool=run_cli_app).",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
user_msg = str(messages[-1]["content"])
|
user_msg = str(messages[-1]["content"])
|
||||||
|
|
||||||
assert "CLI App Attachment: @zoom" in user_msg
|
assert "CLI App Attachment: @zoom" in user_msg
|
||||||
assert "tool=run_cli_app" in user_msg
|
assert "tool=run_cli_app" in user_msg
|
||||||
assert "entry_point=cli-anything-zoom" in user_msg
|
assert user_msg.index("please use @zoom tonight") < user_msg.index(
|
||||||
|
"CLI App Attachment: @zoom"
|
||||||
|
)
|
||||||
|
assert messages[-1]["_meta"]["runtime_context"]["sources"] == ["cli_apps"]
|
||||||
|
|
||||||
def test_consecutive_same_role_merged(self, tmp_path):
|
def test_consecutive_same_role_merged(self, tmp_path):
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from importlib.resources import files as pkg_files
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
|
from nanobot.runtime_context import RuntimeContextBlock
|
||||||
|
|
||||||
|
|
||||||
class _FakeDatetime(real_datetime):
|
class _FakeDatetime(real_datetime):
|
||||||
@ -61,8 +62,7 @@ def test_system_prompt_reflects_current_dream_memory_contract(tmp_path) -> None:
|
|||||||
assert "write important facts here" not in prompt
|
assert "write important facts here" not in prompt
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
|
def test_default_user_message_has_no_runtime_context(tmp_path) -> None:
|
||||||
"""Runtime metadata should be merged with the user message."""
|
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
builder = ContextBuilder(workspace)
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
@ -76,19 +76,13 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None:
|
|||||||
assert messages[0]["role"] == "system"
|
assert messages[0]["role"] == "system"
|
||||||
assert "## Current Session" not in messages[0]["content"]
|
assert "## Current Session" not in messages[0]["content"]
|
||||||
|
|
||||||
# Runtime context is now merged with user message into a single message
|
|
||||||
assert messages[-1]["role"] == "user"
|
assert messages[-1]["role"] == "user"
|
||||||
user_content = messages[-1]["content"]
|
user_content = messages[-1]["content"]
|
||||||
assert isinstance(user_content, str)
|
assert user_content == "Return exactly: OK"
|
||||||
assert ContextBuilder._RUNTIME_CONTEXT_TAG in user_content
|
assert "_meta" not in messages[-1]
|
||||||
assert "Current Time:" in user_content
|
|
||||||
assert "Channel: cli" in user_content
|
|
||||||
assert "Chat ID: direct" in user_content
|
|
||||||
assert "Return exactly: OK" in user_content
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_context_appended_after_user_content(tmp_path) -> None:
|
def test_provider_context_appended_after_user_content(tmp_path) -> None:
|
||||||
"""User content must precede runtime context for prompt-cache prefix stability."""
|
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
builder = ContextBuilder(workspace)
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
@ -97,16 +91,18 @@ def test_runtime_context_appended_after_user_content(tmp_path) -> None:
|
|||||||
current_message="hello world",
|
current_message="hello world",
|
||||||
channel="cli",
|
channel="cli",
|
||||||
chat_id="direct",
|
chat_id="direct",
|
||||||
|
runtime_context_blocks=[
|
||||||
|
RuntimeContextBlock(source="test", content="provider context"),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
content = messages[-1]["content"]
|
content = messages[-1]["content"]
|
||||||
user_pos = content.find("hello world")
|
user_pos = content.find("hello world")
|
||||||
tag_pos = content.find(ContextBuilder._RUNTIME_CONTEXT_TAG)
|
context_pos = content.find("provider context")
|
||||||
assert user_pos < tag_pos, "user content must precede runtime context for prefix stability"
|
assert user_pos < context_pos, "user content must precede provider context"
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_context_includes_sender_id_when_provided(tmp_path) -> None:
|
def test_sender_id_is_not_injected_without_provider(tmp_path) -> None:
|
||||||
"""Sender ID should be included in runtime context when provided."""
|
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
builder = ContextBuilder(workspace)
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
@ -120,7 +116,8 @@ def test_runtime_context_includes_sender_id_when_provided(tmp_path) -> None:
|
|||||||
|
|
||||||
user_content = messages[-1]["content"]
|
user_content = messages[-1]["content"]
|
||||||
assert isinstance(user_content, str)
|
assert isinstance(user_content, str)
|
||||||
assert "Sender ID: user-12345" in user_content
|
assert user_content == "Return exactly: OK"
|
||||||
|
assert "Sender ID:" not in user_content
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_context_excludes_sender_id_when_not_provided(tmp_path) -> None:
|
def test_runtime_context_excludes_sender_id_when_not_provided(tmp_path) -> None:
|
||||||
|
|||||||
@ -7,15 +7,16 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
|
||||||
from nanobot.agent.goal_permission import goal_mutation_allowed, goal_mutation_permission
|
from nanobot.agent.goal_permission import goal_mutation_allowed, goal_mutation_permission
|
||||||
from nanobot.bus.outbound_events import StreamedResponseEvent
|
from nanobot.bus.outbound_events import StreamedResponseEvent
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import AgentDefaults
|
||||||
from nanobot.providers.base import GenerationSettings, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import GenerationSettings, LLMProvider, LLMResponse, ToolCallRequest
|
||||||
|
from nanobot.runtime_context import RuntimeContextBlock, public_history_message
|
||||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||||
|
_GOAL_RUNTIME_GUIDANCE_TAG = "[Goal Runtime Guidance — host instructions]"
|
||||||
|
|
||||||
|
|
||||||
def _make_loop(tmp_path):
|
def _make_loop(tmp_path):
|
||||||
@ -124,15 +125,117 @@ async def test_goal_command_can_implement_plan_from_prior_discussion(tmp_path):
|
|||||||
first_request = provider.chat_with_retry.await_args_list[0].kwargs["messages"]
|
first_request = provider.chat_with_retry.await_args_list[0].kwargs["messages"]
|
||||||
assert "staged migration plan" in str(first_request)
|
assert "staged migration plan" in str(first_request)
|
||||||
assert "/goal implement the plan above" in str(first_request)
|
assert "/goal implement the plan above" in str(first_request)
|
||||||
assert ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG in str(first_request)
|
assert _GOAL_RUNTIME_GUIDANCE_TAG in str(first_request)
|
||||||
final_request = provider.chat_with_retry.await_args_list[-1].kwargs["messages"]
|
final_request = provider.chat_with_retry.await_args_list[-1].kwargs["messages"]
|
||||||
assert "create_goal is unavailable for this turn" in str(final_request)
|
assert "create_goal is unavailable for this turn" in str(final_request)
|
||||||
assert all(
|
assert _GOAL_RUNTIME_GUIDANCE_TAG in str(session.messages[2]["content"])
|
||||||
ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG not in str(message.get("content") or "")
|
assert _GOAL_RUNTIME_GUIDANCE_TAG not in str(
|
||||||
for message in session.messages
|
public_history_message(session.messages[2])["content"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runtime_context_is_persisted_as_next_turn_prompt_prefix(tmp_path):
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
provider.generation = GenerationSettings()
|
||||||
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||||
|
LLMResponse(content="first answer", usage={}),
|
||||||
|
LLMResponse(content="second answer", usage={}),
|
||||||
|
])
|
||||||
|
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||||
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
||||||
|
session = loop.sessions.get_or_create("cli:direct")
|
||||||
|
provider_calls: list[str | None] = []
|
||||||
|
|
||||||
|
async def provide_context(request):
|
||||||
|
provider_calls.append(request.turn_id)
|
||||||
|
return RuntimeContextBlock(source="test", content="stable provider context")
|
||||||
|
|
||||||
|
loop.register_runtime_context_provider(provide_context)
|
||||||
|
loop.register_runtime_context_provider(provide_context)
|
||||||
|
|
||||||
|
await loop._process_message(InboundMessage(
|
||||||
|
channel="cli",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="direct",
|
||||||
|
content="first turn",
|
||||||
|
))
|
||||||
|
await loop._process_message(InboundMessage(
|
||||||
|
channel="cli",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="direct",
|
||||||
|
content="second turn",
|
||||||
|
))
|
||||||
|
|
||||||
|
first_request = provider.chat_with_retry.await_args_list[0].kwargs["messages"]
|
||||||
|
second_request = provider.chat_with_retry.await_args_list[1].kwargs["messages"]
|
||||||
|
first_wire = LLMProvider._sanitize_empty_content(first_request)
|
||||||
|
second_wire = LLMProvider._sanitize_empty_content(second_request)
|
||||||
|
assert second_wire[: len(first_wire)] == first_wire
|
||||||
|
assert first_wire[1] == second_wire[1]
|
||||||
|
assert second_wire[2]["role"] == "assistant"
|
||||||
|
assert second_wire[2]["content"] == "first answer"
|
||||||
|
assert second_wire[3]["content"].startswith("second turn")
|
||||||
|
assert "Current Time:" not in str(second_wire)
|
||||||
|
assert "Chat ID:" not in str(second_wire)
|
||||||
|
assert len(provider_calls) == 2
|
||||||
|
|
||||||
|
persisted_first_user = session.messages[0]
|
||||||
|
assert persisted_first_user["content"] == first_wire[1]["content"]
|
||||||
|
assert public_history_message(persisted_first_user)["content"] == "first turn"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runtime_context_provider_runs_once_across_tool_iterations(tmp_path):
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.events import InboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
|
(tmp_path / "note.txt").write_text("hello", encoding="utf-8")
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
provider.generation = GenerationSettings()
|
||||||
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||||
|
LLMResponse(
|
||||||
|
content="reading",
|
||||||
|
tool_calls=[ToolCallRequest(
|
||||||
|
id="call_read",
|
||||||
|
name="read_file",
|
||||||
|
arguments={"path": "note.txt"},
|
||||||
|
)],
|
||||||
|
usage={},
|
||||||
|
),
|
||||||
|
LLMResponse(content="done", usage={}),
|
||||||
|
])
|
||||||
|
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||||
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
||||||
|
provider_calls = 0
|
||||||
|
|
||||||
|
async def provide_context(_request):
|
||||||
|
nonlocal provider_calls
|
||||||
|
provider_calls += 1
|
||||||
|
return RuntimeContextBlock(source="test", content="frozen context")
|
||||||
|
|
||||||
|
loop.register_runtime_context_provider(provide_context)
|
||||||
|
|
||||||
|
await loop._process_message(InboundMessage(
|
||||||
|
channel="cli",
|
||||||
|
sender_id="user",
|
||||||
|
chat_id="direct",
|
||||||
|
content="read the note",
|
||||||
|
))
|
||||||
|
|
||||||
|
assert provider.chat_with_retry.await_count == 2
|
||||||
|
assert provider_calls == 1
|
||||||
|
for call in provider.chat_with_retry.await_args_list:
|
||||||
|
assert "frozen context" in str(call.kwargs["messages"])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_non_goal_direct_turn_cannot_reuse_prior_goal_command(tmp_path):
|
async def test_non_goal_direct_turn_cannot_reuse_prior_goal_command(tmp_path):
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
|||||||
@ -18,8 +18,15 @@ from nanobot.bus.outbound_events import (
|
|||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
|
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse
|
from nanobot.providers.base import LLMResponse
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
|
from nanobot.runtime_context import (
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
|
RUNTIME_CONTEXT_MESSAGE_META,
|
||||||
|
RuntimeContextBlock,
|
||||||
|
append_runtime_context,
|
||||||
|
public_history_message,
|
||||||
|
)
|
||||||
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
|
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
|
||||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
@ -48,19 +55,13 @@ def _mk_loop() -> AgentLoop:
|
|||||||
return loop
|
return loop
|
||||||
|
|
||||||
|
|
||||||
def _host_text_message(content: str, suffix: str) -> dict:
|
def _runtime_message(content, blocks: list[RuntimeContextBlock]) -> dict:
|
||||||
|
merged, marker = append_runtime_context(content, blocks)
|
||||||
|
assert marker is not None
|
||||||
return {
|
return {
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": content,
|
"content": merged,
|
||||||
"_meta": {ContextBuilder._HOST_TEXT_SUFFIX_META_KEY: suffix},
|
"_meta": {RUNTIME_CONTEXT_MESSAGE_META: marker},
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _host_text_block(text: str) -> dict:
|
|
||||||
return {
|
|
||||||
"type": "text",
|
|
||||||
"text": text,
|
|
||||||
"_meta": {ContextBuilder._HOST_BLOCK_META_KEY: True},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -357,74 +358,80 @@ def test_webui_title_update_uses_captured_llm_runtime(
|
|||||||
assert captured["model"] == "turn-model"
|
assert captured["model"] == "turn-model"
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
|
def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:runtime-only")
|
session = Session(key="test:runtime-only")
|
||||||
runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)"
|
block = RuntimeContextBlock(source="test", content="provider context")
|
||||||
|
|
||||||
loop._save_turn(
|
loop._save_turn(
|
||||||
session,
|
session,
|
||||||
[{"role": "user", "content": [_host_text_block(runtime)]}],
|
[_runtime_message([], [block])],
|
||||||
skip=0,
|
skip=0,
|
||||||
)
|
)
|
||||||
assert session.messages == []
|
assert session.messages[0]["content"] == [
|
||||||
|
{"type": "text", "text": "provider context"}
|
||||||
|
]
|
||||||
|
assert public_history_message(session.messages[0])["content"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_keeps_image_placeholder_with_path_after_runtime_strip() -> None:
|
def test_save_turn_keeps_image_placeholder_and_runtime_context() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:image")
|
session = Session(key="test:image")
|
||||||
runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)"
|
block = RuntimeContextBlock(source="test", content="provider context")
|
||||||
|
|
||||||
loop._save_turn(
|
loop._save_turn(
|
||||||
session,
|
session,
|
||||||
[{
|
[_runtime_message(
|
||||||
"role": "user",
|
[
|
||||||
"content": [
|
|
||||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}, "_meta": {"path": "/media/feishu/photo.jpg"}},
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}, "_meta": {"path": "/media/feishu/photo.jpg"}},
|
||||||
_host_text_block(runtime),
|
|
||||||
],
|
],
|
||||||
}],
|
[block],
|
||||||
|
)],
|
||||||
skip=0,
|
skip=0,
|
||||||
)
|
)
|
||||||
assert session.messages[0]["content"] == [{"type": "text", "text": "[image: /media/feishu/photo.jpg]"}]
|
assert session.messages[0]["content"] == [
|
||||||
|
{"type": "text", "text": "[image: /media/feishu/photo.jpg]"},
|
||||||
|
{"type": "text", "text": "provider context"},
|
||||||
|
]
|
||||||
|
assert public_history_message(session.messages[0])["content"] == [
|
||||||
|
{"type": "text", "text": "[image: /media/feishu/photo.jpg]"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_keeps_image_placeholder_without_meta() -> None:
|
def test_save_turn_keeps_image_placeholder_without_meta() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:image-no-meta")
|
session = Session(key="test:image-no-meta")
|
||||||
runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)"
|
block = RuntimeContextBlock(source="test", content="provider context")
|
||||||
|
|
||||||
loop._save_turn(
|
loop._save_turn(
|
||||||
session,
|
session,
|
||||||
[{
|
[_runtime_message(
|
||||||
"role": "user",
|
[
|
||||||
"content": [
|
|
||||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
|
||||||
_host_text_block(runtime),
|
|
||||||
],
|
],
|
||||||
}],
|
[block],
|
||||||
|
)],
|
||||||
skip=0,
|
skip=0,
|
||||||
)
|
)
|
||||||
assert session.messages[0]["content"] == [{"type": "text", "text": "[image]"}]
|
assert session.messages[0]["content"] == [
|
||||||
|
{"type": "text", "text": "[image]"},
|
||||||
|
{"type": "text", "text": "provider context"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_strips_host_guidance_suffix_from_string() -> None:
|
def test_save_turn_persists_runtime_context_and_public_view_hides_it() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:suffix-strip")
|
session = Session(key="test:suffix-strip")
|
||||||
guidance = ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG + "\ninternal guidance"
|
block = RuntimeContextBlock(source="goal", content="internal goal guidance")
|
||||||
runtime = (
|
|
||||||
ContextBuilder._RUNTIME_CONTEXT_TAG
|
|
||||||
+ "\nCurrent Time: now\n"
|
|
||||||
+ ContextBuilder._RUNTIME_CONTEXT_END
|
|
||||||
)
|
|
||||||
suffix = f"{guidance}\n\n{runtime}"
|
|
||||||
|
|
||||||
loop._save_turn(
|
loop._save_turn(
|
||||||
session,
|
session,
|
||||||
[_host_text_message(f"hello world\n\n{suffix}", suffix)],
|
[_runtime_message("hello world", [block])],
|
||||||
skip=0,
|
skip=0,
|
||||||
)
|
)
|
||||||
assert session.messages[0]["content"] == "hello world"
|
assert session.messages[0]["content"] == "hello world\n\ninternal goal guidance"
|
||||||
|
assert session.messages[0][RUNTIME_CONTEXT_HISTORY_META]["sources"] == ["goal"]
|
||||||
|
assert public_history_message(session.messages[0])["content"] == "hello world"
|
||||||
|
|
||||||
|
|
||||||
def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_path: Path) -> None:
|
def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_path: Path) -> None:
|
||||||
@ -432,7 +439,7 @@ def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_pat
|
|||||||
session = Session(key="test:user-guidance-literal")
|
session = Session(key="test:user-guidance-literal")
|
||||||
user_text = (
|
user_text = (
|
||||||
"Keep this prefix\n"
|
"Keep this prefix\n"
|
||||||
f"{ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG}\n"
|
"[Goal Runtime Guidance — host instructions]\n"
|
||||||
"This label and everything after it are user-authored."
|
"This label and everything after it are user-authored."
|
||||||
)
|
)
|
||||||
messages = ContextBuilder(tmp_path).build_messages(
|
messages = ContextBuilder(tmp_path).build_messages(
|
||||||
@ -440,10 +447,8 @@ def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_pat
|
|||||||
user_text,
|
user_text,
|
||||||
channel="cli",
|
channel="cli",
|
||||||
chat_id="direct",
|
chat_id="direct",
|
||||||
goal_start_requested=True,
|
|
||||||
)
|
)
|
||||||
assert "_meta" in messages[-1]
|
assert "_meta" not in messages[-1]
|
||||||
assert "_meta" not in LLMProvider._sanitize_empty_content(messages)[-1]
|
|
||||||
|
|
||||||
loop._save_turn(session, messages, skip=1)
|
loop._save_turn(session, messages, skip=1)
|
||||||
|
|
||||||
@ -467,7 +472,6 @@ def test_build_and_save_preserves_multimodal_user_block_starting_with_runtime_ta
|
|||||||
media=[str(image)],
|
media=[str(image)],
|
||||||
channel="cli",
|
channel="cli",
|
||||||
chat_id="direct",
|
chat_id="direct",
|
||||||
goal_start_requested=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
loop._save_turn(session, messages, skip=1)
|
loop._save_turn(session, messages, skip=1)
|
||||||
@ -475,21 +479,18 @@ def test_build_and_save_preserves_multimodal_user_block_starting_with_runtime_ta
|
|||||||
assert {"type": "text", "text": user_text} in session.messages[0]["content"]
|
assert {"type": "text", "text": user_text} in session.messages[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_skips_string_user_when_only_runtime_context_suffix() -> None:
|
def test_save_turn_keeps_string_when_only_runtime_context() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:suffix-only")
|
session = Session(key="test:suffix-only")
|
||||||
runtime = (
|
block = RuntimeContextBlock(source="test", content="provider context")
|
||||||
ContextBuilder._RUNTIME_CONTEXT_TAG
|
|
||||||
+ "\nCurrent Time: now\n"
|
|
||||||
+ ContextBuilder._RUNTIME_CONTEXT_END
|
|
||||||
)
|
|
||||||
|
|
||||||
loop._save_turn(
|
loop._save_turn(
|
||||||
session,
|
session,
|
||||||
[_host_text_message(runtime, runtime)],
|
[_runtime_message("", [block])],
|
||||||
skip=0,
|
skip=0,
|
||||||
)
|
)
|
||||||
assert session.messages == []
|
assert session.messages[0]["content"] == "provider context"
|
||||||
|
assert public_history_message(session.messages[0])["content"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_keeps_tool_results_under_16k() -> None:
|
def test_save_turn_keeps_tool_results_under_16k() -> None:
|
||||||
@ -847,9 +848,10 @@ async def test_internal_continuation_queues_turn_without_fake_user_history(
|
|||||||
assert "Finish the long goal." in queued.content
|
assert "Finish the long goal." in queued.content
|
||||||
|
|
||||||
session = loop.sessions.get_or_create("feishu:c-auto")
|
session = loop.sessions.get_or_create("feishu:c-auto")
|
||||||
|
assert "Finish the long goal." in str(session.messages[0]["content"])
|
||||||
assert [
|
assert [
|
||||||
{k: v for k, v in m.items() if k in {"role", "content"}}
|
{k: v for k, v in m.items() if k in {"role", "content"}}
|
||||||
for m in session.messages
|
for m in map(public_history_message, session.messages)
|
||||||
] == [{"role": "user", "content": "start the goal"}]
|
] == [{"role": "user", "content": "start the goal"}]
|
||||||
|
|
||||||
second = await loop._process_message(queued, pending_queue=asyncio.Queue())
|
second = await loop._process_message(queued, pending_queue=asyncio.Queue())
|
||||||
@ -859,7 +861,7 @@ async def test_internal_continuation_queues_turn_without_fake_user_history(
|
|||||||
session = loop.sessions.get_or_create("feishu:c-auto")
|
session = loop.sessions.get_or_create("feishu:c-auto")
|
||||||
assert [
|
assert [
|
||||||
{k: v for k, v in m.items() if k in {"role", "content"}}
|
{k: v for k, v in m.items() if k in {"role", "content"}}
|
||||||
for m in session.messages
|
for m in map(public_history_message, session.messages)
|
||||||
] == [
|
] == [
|
||||||
{"role": "user", "content": "start the goal"},
|
{"role": "user", "content": "start the goal"},
|
||||||
{"role": "assistant", "content": "done"},
|
{"role": "assistant", "content": "done"},
|
||||||
@ -1399,7 +1401,8 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
|
|||||||
assert "[Message Time:" not in non_system[0]["content"]
|
assert "[Message Time:" not in non_system[0]["content"]
|
||||||
assert "[Message Time:" not in non_system[1]["content"]
|
assert "[Message Time:" not in non_system[1]["content"]
|
||||||
assert non_system[2]["content"].count("subagent result") == 1
|
assert non_system[2]["content"].count("subagent result") == 1
|
||||||
assert "Current Time:" in non_system[2]["content"]
|
assert "Current Time:" not in non_system[2]["content"]
|
||||||
|
assert non_system[2]["content"] == "subagent result"
|
||||||
|
|
||||||
loop.sessions.invalidate("cli:test")
|
loop.sessions.invalidate("cli:test")
|
||||||
persisted = loop.sessions.get_or_create("cli:test")
|
persisted = loop.sessions.get_or_create("cli:test")
|
||||||
|
|||||||
118
tests/agent/test_runtime_context.py
Normal file
118
tests/agent/test_runtime_context.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.tools.context import RequestContext
|
||||||
|
from nanobot.runtime_context import (
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
|
RuntimeContextBlock,
|
||||||
|
append_runtime_context,
|
||||||
|
public_history_message,
|
||||||
|
resolve_runtime_context,
|
||||||
|
)
|
||||||
|
from nanobot.sdk.types import snapshot_from_session
|
||||||
|
from nanobot.session.manager import Session, _message_preview_text
|
||||||
|
from nanobot.session.webui_turns import _title_inputs
|
||||||
|
from nanobot.webui.transcript import _session_user_event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_runtime_context_preserves_provider_order() -> None:
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def first(_request: RequestContext):
|
||||||
|
calls.append("first")
|
||||||
|
return RuntimeContextBlock(source="first", content="one")
|
||||||
|
|
||||||
|
async def second(_request: RequestContext):
|
||||||
|
calls.append("second")
|
||||||
|
return [RuntimeContextBlock(source="second", content="two")]
|
||||||
|
|
||||||
|
blocks = await resolve_runtime_context(
|
||||||
|
[first, second],
|
||||||
|
RequestContext(channel="cli", chat_id="direct"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == ["first", "second"]
|
||||||
|
assert [(block.source, block.content) for block in blocks] == [
|
||||||
|
("first", "one"),
|
||||||
|
("second", "two"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_history_removes_only_trusted_exact_suffix() -> None:
|
||||||
|
block = RuntimeContextBlock(source="goal", content="private goal context")
|
||||||
|
content, marker = append_runtime_context("visible user text", [block])
|
||||||
|
assert marker is not None
|
||||||
|
persisted = {
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META: marker,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert public_history_message(persisted) == {
|
||||||
|
"role": "user",
|
||||||
|
"content": "visible user text",
|
||||||
|
}
|
||||||
|
|
||||||
|
user_authored = {
|
||||||
|
"role": "user",
|
||||||
|
"content": "visible user text\n\nprivate goal context",
|
||||||
|
}
|
||||||
|
assert public_history_message(user_authored) == user_authored
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_history_keeps_content_when_marker_does_not_match() -> None:
|
||||||
|
message = {
|
||||||
|
"role": "user",
|
||||||
|
"content": "user-edited content",
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META: {
|
||||||
|
"version": 1,
|
||||||
|
"sources": ["goal"],
|
||||||
|
"suffix": "different suffix",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert public_history_message(message) == {
|
||||||
|
"role": "user",
|
||||||
|
"content": "user-edited content",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sdk_snapshot_hides_runtime_context() -> None:
|
||||||
|
block = RuntimeContextBlock(source="goal", content="private goal context")
|
||||||
|
content, marker = append_runtime_context("visible user text", [block])
|
||||||
|
session = SimpleNamespace(
|
||||||
|
key="cli:direct",
|
||||||
|
created_at=SimpleNamespace(isoformat=lambda: "created"),
|
||||||
|
updated_at=SimpleNamespace(isoformat=lambda: "updated"),
|
||||||
|
metadata={},
|
||||||
|
messages=[{
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META: marker,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = snapshot_from_session(session)
|
||||||
|
|
||||||
|
assert snapshot.messages == [{"role": "user", "content": "visible user text"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_preview_title_and_backfill_hide_runtime_context() -> None:
|
||||||
|
block = RuntimeContextBlock(source="goal", content="private goal context")
|
||||||
|
content, marker = append_runtime_context("visible user text", [block])
|
||||||
|
persisted = {
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META: marker,
|
||||||
|
}
|
||||||
|
session = Session(key="websocket:chat", messages=[persisted])
|
||||||
|
|
||||||
|
assert _message_preview_text(persisted) == "visible user text"
|
||||||
|
assert _title_inputs(session) == ("visible user text", "")
|
||||||
|
event = _session_user_event("websocket:chat", persisted)
|
||||||
|
assert event is not None
|
||||||
|
assert event["text"] == "visible user text"
|
||||||
@ -1,3 +1,8 @@
|
|||||||
|
from nanobot.runtime_context import (
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
|
RuntimeContextBlock,
|
||||||
|
append_runtime_context,
|
||||||
|
)
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
|
||||||
|
|
||||||
@ -425,6 +430,41 @@ def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
|
|||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_history_does_not_duplicate_persisted_capability_runtime_context():
|
||||||
|
content, marker = append_runtime_context(
|
||||||
|
"please use @drawio",
|
||||||
|
[RuntimeContextBlock(
|
||||||
|
source="cli_apps",
|
||||||
|
content="[Runtime Context]\nCLI App Attachment: @drawio",
|
||||||
|
), RuntimeContextBlock(
|
||||||
|
source="mcp",
|
||||||
|
content="[Runtime Context]\nMCP Preset Attachment: @linear",
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
session = Session(key="test:cli-app-persisted")
|
||||||
|
session.messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
"cli_apps": [{
|
||||||
|
"name": "drawio",
|
||||||
|
"entry_point": "cli-anything-drawio",
|
||||||
|
}],
|
||||||
|
"mcp_presets": [{"name": "linear", "transport": "stdio"}],
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META: marker,
|
||||||
|
})
|
||||||
|
|
||||||
|
model_history = session.get_history(max_messages=500)
|
||||||
|
public_history = session.get_history(
|
||||||
|
max_messages=500,
|
||||||
|
include_runtime_context=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert model_history == [{"role": "user", "content": content}]
|
||||||
|
assert model_history[0]["content"].count("CLI App Attachment: @drawio") == 1
|
||||||
|
assert model_history[0]["content"].count("MCP Preset Attachment: @linear") == 1
|
||||||
|
assert public_history == [{"role": "user", "content": "please use @drawio"}]
|
||||||
|
|
||||||
|
|
||||||
def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
|
def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
source = manager.get_or_create("websocket:source")
|
source = manager.get_or_create("websocket:source")
|
||||||
|
|||||||
@ -21,6 +21,11 @@ from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
|
|||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||||
from nanobot.optional_features import InstallResult
|
from nanobot.optional_features import InstallResult
|
||||||
|
from nanobot.runtime_context import (
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
|
RuntimeContextBlock,
|
||||||
|
append_runtime_context,
|
||||||
|
)
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
@ -1739,6 +1744,42 @@ async def test_session_routes_accept_percent_encoded_websocket_keys(
|
|||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_messages_hide_persisted_runtime_context(
|
||||||
|
bus: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
sm = SessionManager(tmp_path)
|
||||||
|
session = sm.get_or_create("websocket:runtime-context")
|
||||||
|
content, marker = append_runtime_context(
|
||||||
|
"visible user text",
|
||||||
|
[RuntimeContextBlock(source="goal", content="private goal context")],
|
||||||
|
)
|
||||||
|
session.add_message(
|
||||||
|
"user",
|
||||||
|
content,
|
||||||
|
**{RUNTIME_CONTEXT_HISTORY_META: marker},
|
||||||
|
)
|
||||||
|
sm.save(session)
|
||||||
|
channel = _ch(bus, session_manager=sm, port=29919)
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
try:
|
||||||
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
|
response = await _http_get(
|
||||||
|
"http://127.0.0.1:29919/api/sessions/websocket:runtime-context/messages",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
message = response.json()["messages"][0]
|
||||||
|
assert message["content"] == "visible user text"
|
||||||
|
assert RUNTIME_CONTEXT_HISTORY_META not in message
|
||||||
|
assert "private goal context" not in response.text
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_webui_thread_resigns_assistant_media_urls(
|
async def test_webui_thread_resigns_assistant_media_urls(
|
||||||
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from nanobot.agent.tools.cli_apps import CliAppsTool
|
from nanobot.agent.tools.cli_apps import CliAppsTool
|
||||||
|
from nanobot.agent.tools.context import RequestContext
|
||||||
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
@ -125,3 +126,33 @@ def test_run_cli_app_description_names_only_settings_installed_apps(tmp_path: Pa
|
|||||||
|
|
||||||
assert "Settings CLI Apps: drawio" in tool.description
|
assert "Settings CLI Apps: drawio" in tool.description
|
||||||
assert "ordinary system CLIs such as git, gh" in tool.description
|
assert "ordinary system CLIs such as git, gh" in tool.description
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_app_tool_provides_context_only_for_attachment(tmp_path: Path) -> None:
|
||||||
|
tool = CliAppsTool(workspace=tmp_path)
|
||||||
|
provider = tool.runtime_context_provider()
|
||||||
|
assert provider is not None
|
||||||
|
|
||||||
|
empty = asyncio.run(provider(RequestContext(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat",
|
||||||
|
original_user_text="hello",
|
||||||
|
workspace=tmp_path,
|
||||||
|
)))
|
||||||
|
attached = asyncio.run(provider(RequestContext(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat",
|
||||||
|
original_user_text="use @drawio",
|
||||||
|
metadata={
|
||||||
|
"cli_apps": [{
|
||||||
|
"name": "drawio",
|
||||||
|
"entry_point": "cli-anything-drawio",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
workspace=tmp_path,
|
||||||
|
)))
|
||||||
|
|
||||||
|
assert empty is None
|
||||||
|
assert attached is not None
|
||||||
|
assert attached.source == "cli_apps"
|
||||||
|
assert "CLI App Attachment: @drawio" in attached.content
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user