feat(agent): add persistent runtime context providers

This commit is contained in:
chengyongru
2026-07-12 00:35:17 +08:00
committed by Xubin Ren
parent 7f8c3453e1
commit f75d3519db
27 changed files with 789 additions and 371 deletions
+89 -37
View File
@@ -12,6 +12,7 @@ from dataclasses import dataclass, field
from enum import Enum, auto
from functools import partial
from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger
@@ -27,6 +28,7 @@ from nanobot.agent.memory import Consolidator
from nanobot.agent.model_runtime import ModelRuntimeResolver
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools import mcp as mcp_tools
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
from nanobot.agent.tools.message import MessageTool
@@ -52,6 +54,15 @@ from nanobot.command import CommandContext, CommandRouter, register_builtin_comm
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
RUNTIME_CONTEXT_MESSAGE_META,
RuntimeContextBlock,
RuntimeContextProvider,
append_runtime_context,
resolve_runtime_context,
wrap_runtime_context_lines,
)
from nanobot.security.workspace_access import (
WorkspaceScopeResolver,
bind_workspace_scope,
@@ -60,7 +71,6 @@ from nanobot.security.workspace_access import (
from nanobot.session import turn_continuation
from nanobot.session.automation_turns import automation_history_overrides
from nanobot.session.goal_state import (
explicit_goal_requested,
goal_state_runtime_lines,
runner_wall_llm_timeout_s,
sustained_goal_active,
@@ -123,6 +133,8 @@ class TurnContext:
history: list[dict[str, Any]] = field(default_factory=list)
initial_messages: list[dict[str, Any]] = field(default_factory=list)
request_context: RequestContext | None = None
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
final_content: str | None = None
tools_used: list[str] = field(default_factory=list)
@@ -365,6 +377,8 @@ class AgentLoop:
self._mcp_servers = mcp_servers or {}
self._mcp_stacks: dict[str, MCPConnection] = {}
self._mcp_connecting = False
self._runtime_context_providers: list[RuntimeContextProvider] = []
self.register_runtime_context_provider(self._provide_mcp_runtime_context)
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
self._background_tasks: list[asyncio.Task] = []
self._session_locks: dict[str, asyncio.Lock] = {}
@@ -554,6 +568,14 @@ class AgentLoop:
"""Connect configured MCP servers."""
await agent_context.connect_mcp(self, self.tools)
def register_runtime_context_provider(
self,
provider: RuntimeContextProvider,
) -> None:
"""Register a provider resolved once before each inbound model turn."""
if provider not in self._runtime_context_providers:
self._runtime_context_providers.append(provider)
@staticmethod
def _runtime_chat_id(msg: InboundMessage) -> str:
"""Return the chat id shown in runtime metadata for the model."""
@@ -608,6 +630,7 @@ class AgentLoop:
self,
msg: InboundMessage,
session: Session,
runtime_context_blocks: list[RuntimeContextBlock] | None = None,
**kwargs: Any,
) -> bool:
"""Persist the triggering user message before the turn starts.
@@ -618,7 +641,7 @@ class AgentLoop:
return False
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
has_text = isinstance(msg.content, str) and msg.content.strip()
if has_text or media_paths:
if has_text or media_paths or runtime_context_blocks:
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else ""
@@ -626,6 +649,12 @@ class AgentLoop:
if text_override is not None:
text = text_override
extra.update(automation_extra)
text, runtime_context_meta = append_runtime_context(
text,
runtime_context_blocks or (),
)
if runtime_context_meta is not None:
extra[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
session.add_message("user", text, **extra)
self._mark_pending_user_turn(session)
self.sessions.save(session)
@@ -639,7 +668,7 @@ class AgentLoop:
history: list[dict[str, Any]],
pending_summary: str | None,
include_memory_recent_history: bool = True,
goal_start_requested: bool = False,
runtime_context_blocks: list[RuntimeContextBlock] | None = None,
) -> list[dict[str, Any]]:
"""Build the initial message list for the LLM turn."""
scope = self.workspace_scopes.for_message(msg, session.metadata)
@@ -653,14 +682,53 @@ class AgentLoop:
session_summary=pending_summary,
session_metadata=session.metadata,
workspace=scope.project_path,
runtime_state=self,
inbound_message=msg,
goal_start_requested=goal_start_requested,
runtime_context_blocks=runtime_context_blocks,
include_memory_recent_history=include_memory_recent_history,
session_key=session.key,
unified_session=self._unified_session,
)
async def _provide_mcp_runtime_context(
self,
request: RequestContext,
) -> RuntimeContextBlock | None:
lines = mcp_tools.runtime_lines(
SimpleNamespace(metadata=request.metadata),
configured_server_names=set(self._mcp_servers),
connected_server_names=set(self._mcp_stacks),
)
content = wrap_runtime_context_lines(lines)
if not content:
return None
return RuntimeContextBlock(source="mcp", content=content)
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
scope = self.workspace_scopes.for_message(ctx.msg, ctx.session.metadata)
return RequestContext(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
message_id=ctx.msg.metadata.get("message_id"),
session_key=ctx.session_key,
original_user_text=ctx.original_user_text,
runtime=ctx.runtime,
metadata=dict(ctx.msg.metadata or {}),
sender_id=ctx.msg.sender_id,
turn_id=ctx.turn_id,
workspace=scope.project_path,
)
async def _resolve_runtime_context_for_turn(
self,
ctx: TurnContext,
) -> list[RuntimeContextBlock]:
tools = ctx.tools or self.tools
providers = [
*tools.get_runtime_context_providers(),
*self._runtime_context_providers,
]
assert ctx.request_context is not None
return await resolve_runtime_context(providers, ctx.request_context)
async def _dispatch_command_inline(
self,
msg: InboundMessage,
@@ -731,6 +799,7 @@ class AgentLoop:
hook_factories: list[AgentTurnHookFactory] | None = None,
turn_scopes: list[AbstractContextManager[Any]] | None = None,
tools: ToolRegistry | None = None,
request_context: RequestContext | None = None,
) -> tuple[str | None, list[str], list[dict], str, bool]:
"""Run the agent iteration loop.
@@ -819,7 +888,7 @@ class AgentLoop:
session_metadata=session.metadata if session is not None else None,
)
effective_tools = tools or self.tools
request_ctx = RequestContext(
request_ctx = request_context or RequestContext(
channel=channel,
chat_id=chat_id,
message_id=message_id,
@@ -827,6 +896,7 @@ class AgentLoop:
original_user_text=original_user_text,
runtime=runtime,
metadata=dict(metadata or {}),
workspace=effective_scope.project_path,
)
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
request_token = bind_request_context(request_ctx)
@@ -1259,9 +1329,6 @@ class AgentLoop:
session_summary=pending,
session_metadata=session.metadata,
workspace=workspace_scope.project_path,
runtime_state=self,
inbound_message=msg,
skip_runtime_lines=is_subagent,
session_key=key,
unified_session=self._unified_session,
)
@@ -1561,16 +1628,20 @@ class AgentLoop:
ctx.runtime,
)
ctx.request_context = self._request_context_for_turn(ctx)
ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx)
ctx.initial_messages = self._build_initial_messages(
ctx.msg,
ctx.session,
ctx.history,
ctx.pending_summary,
include_memory_recent_history=not ctx.ephemeral,
goal_start_requested=explicit_goal_requested(ctx.msg.metadata),
runtime_context_blocks=ctx.runtime_context_blocks,
)
ctx.user_persisted_early = self._persist_user_message_early(
ctx.msg, ctx.session
ctx.msg,
ctx.session,
runtime_context_blocks=ctx.runtime_context_blocks,
)
if ctx.on_progress is None:
@@ -1610,6 +1681,7 @@ class AgentLoop:
hook_factories=ctx.hook_factories,
turn_scopes=ctx.turn_scopes,
tools=ctx.tools,
request_context=ctx.request_context,
)
final_content, tools_used, all_msgs, stop_reason, had_injections = result
ctx.final_content = final_content
@@ -1684,7 +1756,6 @@ class AgentLoop:
content: list[dict[str, Any]],
*,
should_truncate_text: bool = False,
drop_runtime: bool = False,
) -> list[dict[str, Any]]:
"""Strip volatile multimodal payloads before writing session history."""
filtered: list[dict[str, Any]] = []
@@ -1693,14 +1764,6 @@ class AgentLoop:
filtered.append(block)
continue
if (
drop_runtime
and block.get("type") == "text"
and isinstance(block.get("_meta"), dict)
and block["_meta"].get(ContextBuilder._HOST_BLOCK_META_KEY) is True
):
continue
if block.get("type") == "image_url" and block.get("image_url", {}).get(
"url", ""
).startswith("data:image/"):
@@ -1741,8 +1804,8 @@ class AgentLoop:
for m in messages[skip:]:
entry = dict(m)
internal_meta = entry.pop("_meta", None)
host_text_suffix = (
internal_meta.get(ContextBuilder._HOST_TEXT_SUFFIX_META_KEY)
runtime_context_meta = (
internal_meta.get(RUNTIME_CONTEXT_MESSAGE_META)
if isinstance(internal_meta, dict)
else None
)
@@ -1770,24 +1833,13 @@ class AgentLoop:
]
entry["content"] = filtered
elif role == "user":
if (
isinstance(content, str)
and isinstance(host_text_suffix, str)
and host_text_suffix
and content.endswith(host_text_suffix)
):
before = content[: -len(host_text_suffix)]
if before.endswith("\n\n"):
before = before[:-2]
if before:
entry["content"] = before
else:
continue
if isinstance(content, list):
filtered = self._sanitize_persisted_blocks(content, drop_runtime=True)
filtered = self._sanitize_persisted_blocks(content)
if not filtered:
continue
entry["content"] = filtered
if isinstance(runtime_context_meta, dict):
entry[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry)
if role == "assistant":