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
+5
View File
@@ -11,6 +11,7 @@ if typing.TYPE_CHECKING:
from pydantic import BaseModel
from nanobot.agent.tools.context import ToolContext
from nanobot.runtime_context import RuntimeContextProvider
_ToolT = TypeVar("_ToolT", bound="Tool")
@@ -206,6 +207,10 @@ class Tool(ABC):
def create(cls, ctx: ToolContext) -> Tool:
return cls()
def runtime_context_provider(self) -> RuntimeContextProvider | None:
"""Return optional per-turn prompt context owned by this tool."""
return None
@abstractmethod
async def execute(self, **kwargs: Any) -> Any:
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
+20
View File
@@ -8,6 +8,7 @@ from typing import Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
@@ -16,7 +17,9 @@ from nanobot.agent.tools.schema import (
tool_parameters_schema,
)
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.apps.cli.utils import runtime_lines_for_request
from nanobot.config_base import Base
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
from nanobot.security.workspace_access import current_tool_workspace
@@ -112,6 +115,23 @@ class CliAppsTool(Tool):
+ installed_note
)
def runtime_context_provider(self):
return self._provide_runtime_context
async def _provide_runtime_context(
self,
request: RequestContext,
) -> RuntimeContextBlock | None:
lines = runtime_lines_for_request(
request.original_user_text or "",
request.metadata,
request.workspace or self.workspace,
)
content = wrap_runtime_context_lines(lines)
if not content:
return None
return RuntimeContextBlock(source="cli_apps", content=content)
async def execute(
self,
name: str,
+4
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
if TYPE_CHECKING:
@@ -25,6 +26,9 @@ class RequestContext:
original_user_text: str | None = None
runtime: LLMRuntime | None = None
metadata: dict[str, Any] = field(default_factory=dict)
sender_id: str | None = None
turn_id: str | None = None
workspace: Path | None = None
@runtime_checkable
+3
View File
@@ -138,6 +138,9 @@ class _LegacyErrorPrefixTool(Tool):
def parameters(self) -> dict[str, Any]:
return self._wrapped.parameters
def runtime_context_provider(self):
return self._wrapped.runtime_context_provider()
@property
def read_only(self) -> bool:
return self._wrapped.read_only
+31 -1
View File
@@ -11,17 +11,22 @@ from nanobot.agent.goal_permission import (
revoke_goal_mutation_permission,
)
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.context import RequestContext, current_request_context
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
from nanobot.session.goal_state import (
GOAL_STATE_KEY,
MAX_GOAL_OBJECTIVE_CHARS,
discard_legacy_goal_state_key,
explicit_goal_requested,
goal_state_raw,
goal_state_runtime_lines,
parse_goal_state,
sustained_goal_active,
)
from nanobot.session.turn_continuation import reset_goal_continuation_rounds
from nanobot.utils.prompt_templates import render_template
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
@@ -158,6 +163,31 @@ class CreateGoalTool(Tool, _GoalToolsMixin):
"completion criteria. Do not retry after a successful creation."
)
def runtime_context_provider(self):
return self._provide_runtime_context
async def _provide_runtime_context(
self,
request: RequestContext,
) -> RuntimeContextBlock | None:
if not request.session_key:
return None
session = self._sessions.get_or_create(request.session_key)
goal_start_requested = explicit_goal_requested(request.metadata)
goal_active = sustained_goal_active(session.metadata)
if not goal_start_requested and not goal_active:
return None
guidance = render_template(
"agent/goal_runtime.md",
strip=True,
goal_start_requested=goal_start_requested,
goal_active=goal_active,
)
state = wrap_runtime_context_lines(goal_state_runtime_lines(session.metadata))
content = "\n\n".join(part for part in (guidance, state) if part)
return RuntimeContextBlock(source="goal", content=content)
async def execute(
self,
objective: str,
+15 -1
View File
@@ -1,11 +1,16 @@
"""Tool registry for dynamic tool management."""
from __future__ import annotations
import json
from typing import Any
from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import ContextAware, current_request_context
if TYPE_CHECKING:
from nanobot.runtime_context import RuntimeContextProvider
def is_tool_error_result(name: str, result: Any) -> bool:
return isinstance(result, ToolResult) and result.is_error
@@ -36,6 +41,15 @@ class ToolRegistry:
"""Get a tool by name."""
return self._tools.get(name)
def get_runtime_context_providers(self) -> list[RuntimeContextProvider]:
"""Return tool-owned providers in stable tool-name order."""
providers: list[RuntimeContextProvider] = []
for name in sorted(self._tools):
provider = self._tools[name].runtime_context_provider()
if provider is not None:
providers.append(provider)
return providers
@staticmethod
def _lookup_key(name: str) -> str:
"""Normalize names for suggestions only; never for execution."""