refactor(agent): replace reflective runtime state access (#5319)

This commit is contained in:
chengyongru
2026-08-10 16:44:26 +08:00
committed by GitHub
parent 05d73803e7
commit 85a452e5c7
10 changed files with 877 additions and 352 deletions
+11 -3
View File
@@ -36,6 +36,7 @@ from nanobot.agent.tools.exec_session import ExecSessionManager
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.registry import ToolRegistry
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
from nanobot.agent.tools.self import MyTool
from nanobot.agent.turn_delivery import (
TurnDelivery,
@@ -197,6 +198,11 @@ class AgentLoop:
def tool_names(self) -> list[str]:
return self.tools.tool_names
@property
def last_usage(self) -> Mapping[str, int]:
"""Latest aggregate usage exposed through the runtime-control snapshot."""
return self._last_usage
@property
def provider(self) -> LLMProvider:
"""Provider selected for future turn admissions."""
@@ -448,7 +454,6 @@ class AgentLoop:
if model_preset:
self.set_model_preset(model_preset, publish_update=False)
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
self._runtime_vars: dict[str, Any] = {}
self._current_iteration: int = 0
self.commands = CommandRouter()
register_builtin_commands(self.commands)
@@ -623,10 +628,13 @@ class AgentLoop:
loader = ToolLoader()
registered = loader.load(ctx, self.tools)
# MyTool needs runtime state reference — manual registration
# MyTool receives only the explicit runtime-control capability.
if self.tools_config.my.enable:
self.tools.register(
MyTool(runtime_state=self, modify_allowed=self.tools_config.my.allow_set)
MyTool(
runtime_control=AgentRuntimeControl(self),
modify_allowed=self.tools_config.my.allow_set,
)
)
registered.append("my")
+5
View File
@@ -5,6 +5,7 @@ import json
import time
import uuid
import warnings
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, TypedDict
@@ -157,6 +158,10 @@ class SubagentManager:
self._task_statuses: dict[str, SubagentStatus] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
def runtime_statuses(self) -> Mapping[str, SubagentStatus]:
"""Return the observable task statuses used by runtime-control snapshots."""
return self._task_statuses
def set_provider(self, provider: LLMProvider, model: str) -> None:
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
warnings.warn(
+1 -1
View File
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
_SKIP_MODULES = frozenset({
"base", "schema", "registry", "context", "loader", "config",
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
"file_state", "sandbox", "mcp", "__init__", "runtime_control",
})
+319
View File
@@ -0,0 +1,319 @@
"""Explicit runtime state boundary used by :class:`MyTool`."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Protocol, TypeAlias, runtime_checkable
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.agent.tools.shell import ExecToolConfig
from nanobot.agent.tools.web import WebToolsConfig
from nanobot.config.schema import ModelPresetConfig
from nanobot.utils.llm_runtime import LLMRuntime
JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
RUNTIME_SNAPSHOT_KEYS = frozenset({
"model",
"model_preset",
"model_presets",
"max_iterations",
"context_window_tokens",
"workspace",
"provider_retry_mode",
"max_tool_result_chars",
"current_iteration",
"_current_iteration",
"tool_names",
"web_config",
"exec_config",
"subagents",
"_last_usage",
})
RUNTIME_COMMAND_KEYS = frozenset({
"model",
"model_preset",
"max_iterations",
"context_window_tokens",
"provider_retry_mode",
"max_tool_result_chars",
"workspace",
})
@dataclass(frozen=True, slots=True)
class RuntimeSnapshot:
"""Detached, allowlisted values available to self-inspection."""
model: str
model_preset: str | None
model_presets: dict[str, dict[str, object]]
max_iterations: int
context_window_tokens: int
workspace: Path | str
provider_retry_mode: str
max_tool_result_chars: int
current_iteration: int
tool_names: list[str]
web_config: dict[str, object]
exec_config: dict[str, object]
subagent_statuses: dict[str, dict[str, object]]
last_usage: dict[str, int]
scratchpad: dict[str, JsonValue]
def as_mapping(self) -> Mapping[str, object]:
"""Return the fixed public names understood by ``MyTool``."""
values: dict[str, object] = {
"model": self.model,
"model_preset": self.model_preset,
"model_presets": self.model_presets,
"max_iterations": self.max_iterations,
"context_window_tokens": self.context_window_tokens,
"workspace": self.workspace,
"provider_retry_mode": self.provider_retry_mode,
"max_tool_result_chars": self.max_tool_result_chars,
"current_iteration": self.current_iteration,
"_current_iteration": self.current_iteration,
"tool_names": self.tool_names,
"web_config": self.web_config,
"exec_config": self.exec_config,
"subagents": {"_task_statuses": self.subagent_statuses},
"_last_usage": self.last_usage,
}
assert values.keys() == RUNTIME_SNAPSHOT_KEYS
return values
@runtime_checkable
class RuntimeControl(Protocol):
"""The complete runtime capability exposed to ``MyTool``."""
def snapshot(self) -> RuntimeSnapshot: ...
def set_model(self, model: str) -> LLMRuntime: ...
def set_model_preset(
self,
name: str,
*,
session_key: str | None,
) -> LLMRuntime: ...
def set_max_iterations(self, value: int) -> None: ...
def set_context_window_tokens(self, value: int) -> LLMRuntime: ...
def set_provider_retry_mode(self, value: str) -> None: ...
def set_max_tool_result_chars(self, value: int) -> None: ...
def set_workspace_display(self, value: str) -> None: ...
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None: ...
class _RuntimeControlTarget(Protocol):
"""Narrow structural dependency required by ``AgentRuntimeControl``."""
max_iterations: int
provider_retry_mode: str
max_tool_result_chars: int
web_config: WebToolsConfig
exec_config: ExecToolConfig
subagents: SubagentManager
@property
def model(self) -> str: ...
@property
def model_preset(self) -> str | None: ...
@property
def model_presets(self) -> Mapping[str, ModelPresetConfig]: ...
@property
def context_window_tokens(self) -> int: ...
@property
def workspace(self) -> Path: ...
@property
def current_iteration(self) -> int: ...
@property
def tool_names(self) -> list[str]: ...
@property
def last_usage(self) -> Mapping[str, int]: ...
def set_runtime_model(self, model: str) -> LLMRuntime: ...
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
def set_model_preset(self, name: str | None) -> LLMRuntime: ...
def set_session_model_preset(self, session_key: str, name: str) -> LLMRuntime: ...
class AgentRuntimeControl:
"""Allowlisted adapter from agent-loop state to ``RuntimeControl``."""
def __init__(self, target: _RuntimeControlTarget) -> None:
self.__target = target
self.__scratchpad: dict[str, JsonValue] = {}
self.__workspace_display: str | None = None
def snapshot(self) -> RuntimeSnapshot:
target = self.__target
return RuntimeSnapshot(
model=target.model,
model_preset=target.model_preset,
model_presets=_snapshot_model_presets(target.model_presets),
max_iterations=target.max_iterations,
context_window_tokens=target.context_window_tokens,
workspace=(
self.__workspace_display
if self.__workspace_display is not None
else target.workspace
),
provider_retry_mode=target.provider_retry_mode,
max_tool_result_chars=target.max_tool_result_chars,
current_iteration=target.current_iteration,
tool_names=list(target.tool_names),
web_config=_snapshot_web_config(target.web_config),
exec_config=_snapshot_exec_config(target.exec_config),
subagent_statuses=_snapshot_subagent_statuses(target.subagents),
last_usage=dict(target.last_usage),
scratchpad=_snapshot_json_mapping(self.__scratchpad),
)
def set_model(self, model: str) -> LLMRuntime:
return self.__target.set_runtime_model(model)
def set_model_preset(
self,
name: str,
*,
session_key: str | None,
) -> LLMRuntime:
if session_key is not None:
return self.__target.set_session_model_preset(session_key, name)
return self.__target.set_model_preset(name)
def set_max_iterations(self, value: int) -> None:
self.__target.max_iterations = value
self.__target.subagents.max_iterations = value
def set_context_window_tokens(self, value: int) -> LLMRuntime:
return self.__target.set_runtime_context_window(value)
def set_provider_retry_mode(self, value: str) -> None:
self.__target.provider_retry_mode = value
def set_max_tool_result_chars(self, value: int) -> None:
self.__target.max_tool_result_chars = value
def set_workspace_display(self, value: str) -> None:
"""Preserve MyTool display compatibility without changing path enforcement."""
self.__workspace_display = value
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None:
if key not in self.__scratchpad and len(self.__scratchpad) >= max_keys:
raise ValueError(f"scratchpad is full (max {max_keys} keys)")
self.__scratchpad[key] = value
def _snapshot_model_presets(
presets: Mapping[str, ModelPresetConfig],
) -> dict[str, dict[str, object]]:
return {
name: {
"label": preset.label,
"model": preset.model,
"provider": preset.provider,
"max_tokens": preset.max_tokens,
"context_window_tokens": preset.context_window_tokens,
"temperature": preset.temperature,
"reasoning_effort": preset.reasoning_effort,
}
for name, preset in presets.items()
}
def _snapshot_web_config(config: WebToolsConfig) -> dict[str, object]:
return {
"enable": config.enable,
# Proxy URLs may embed credentials. Presence is enough for diagnosis.
"proxy": "<configured>" if config.proxy else config.proxy,
"user_agent": config.user_agent,
"search": {
"provider": config.search.provider,
"base_url": config.search.base_url,
"max_results": config.search.max_results,
"timeout": config.search.timeout,
},
"fetch": {
"use_jina_reader": config.fetch.use_jina_reader,
},
}
def _snapshot_exec_config(config: ExecToolConfig) -> dict[str, object]:
return {
"enable": config.enable,
"timeout": config.timeout,
"path_prepend": config.path_prepend,
"path_append": config.path_append,
"sandbox": config.sandbox,
"sandbox_ro_binds": list(config.sandbox_ro_binds),
"sandbox_rw_binds": list(config.sandbox_rw_binds),
"allowed_env_keys": list(config.allowed_env_keys),
"allow_patterns": list(config.allow_patterns),
"deny_patterns": list(config.deny_patterns),
}
def _snapshot_subagent_statuses(
manager: SubagentManager,
) -> dict[str, dict[str, object]]:
return {
task_id: _snapshot_subagent_status(status)
for task_id, status in manager.runtime_statuses().items()
}
def _snapshot_subagent_status(status: SubagentStatus) -> dict[str, object]:
return {
"task_id": status.task_id,
"label": status.label,
"task_description": status.task_description,
"started_at": status.started_at,
"phase": status.phase,
"iteration": status.iteration,
"tool_events": [dict(event) for event in status.tool_events],
"usage": dict(status.usage),
"stop_reason": status.stop_reason,
"error": status.error,
}
def _snapshot_json_mapping(values: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
return {key: _snapshot_json_value(value) for key, value in values.items()}
def _snapshot_json_value(value: JsonValue) -> JsonValue:
if isinstance(value, list):
return [_snapshot_json_value(item) for item in value]
if isinstance(value, dict):
return {
key: _snapshot_json_value(item)
for key, item in value.items()
}
return value
-76
View File
@@ -1,76 +0,0 @@
"""RuntimeState protocol: agent loop state exposed to MyTool."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.shell import ExecToolConfig
from nanobot.agent.tools.web import WebToolsConfig
from nanobot.utils.llm_runtime import LLMRuntime
class RuntimeState(Protocol):
"""Minimum contract that MyTool requires from its runtime state provider.
In practice, this is always satisfied by ``AgentLoop``. MyTool also
accesses arbitrary attributes dynamically (via ``getattr`` / ``setattr``)
for dot-path inspection and modification; those paths are validated at
runtime rather than by this protocol.
"""
@property
def model(self) -> str: ...
@property
def max_iterations(self) -> int: ...
@property
def current_iteration(self) -> int: ...
@property
def tool_names(self) -> list[str]: ...
@property
def workspace(self) -> Path: ...
@property
def provider_retry_mode(self) -> str: ...
@property
def max_tool_result_chars(self) -> int: ...
@property
def context_window_tokens(self) -> int: ...
@property
def web_config(self) -> WebToolsConfig: ...
@property
def exec_config(self) -> ExecToolConfig: ...
@property
def subagents(self) -> SubagentManager: ...
@property
def _runtime_vars(self) -> dict[str, Any]: ...
@property
def _last_usage(self) -> dict[str, int]: ...
def _sync_subagent_runtime_limits(self) -> None: ...
def set_runtime_model(self, model: str) -> LLMRuntime: ...
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
def set_session_model_preset(
self,
session_key: str,
name: str,
) -> LLMRuntime: ...
@property
def model_preset(self) -> str | None: ...
+213 -182
View File
@@ -1,8 +1,7 @@
"""MyTool: runtime state inspection and configuration for the agent loop."""
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
# Tool.execute accepts heterogeneous schemas.
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
@@ -14,7 +13,13 @@ from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import current_request_context, current_request_session_key
from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.agent.tools.runtime_control import (
RUNTIME_COMMAND_KEYS,
RUNTIME_SNAPSHOT_KEYS,
JsonValue,
RuntimeControl,
RuntimeSnapshot,
)
from nanobot.config_base import Base
if TYPE_CHECKING:
@@ -28,25 +33,28 @@ class MyToolConfig(Base):
allow_set: bool = False
def _has_real_attr(obj: Any, key: str) -> bool:
"""Check if obj has a real (explicitly set) attribute, not auto-generated by mock."""
if isinstance(obj, dict):
return key in obj
d = getattr(obj, "__dict__", None)
if d is not None and key in d:
return True
for cls in type(obj).__mro__:
if key in cls.__dict__:
return True
return False
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
from nanobot.agent.subagent import SubagentStatus
return isinstance(value, SubagentStatus)
def _is_subagent_status_snapshot(value: object) -> TypeGuard[Mapping[str, object]]:
if not isinstance(value, Mapping):
return False
return all(
field in value
for field in ("task_id", "label", "task_description", "started_at", "phase")
)
def _is_string_mapping(value: object) -> TypeGuard[Mapping[str, object]]:
if not isinstance(value, Mapping):
return False
mapping = cast(Mapping[object, object], value)
return all(isinstance(key, str) for key in mapping)
class MyTool(Tool):
"""Check and set the agent loop's runtime configuration."""
@@ -79,7 +87,10 @@ class MyTool(Tool):
READ_ONLY = frozenset({
"subagents", # observable but replacing it would break the system
"tool_names",
"current_iteration",
"_current_iteration", # updated by runner only
"_last_usage",
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
"web_config", # inspect allowed (e.g. check enable), modify blocked
"model_presets", # config-derived catalog; changes require config reload
@@ -103,13 +114,6 @@ class MyTool(Tool):
"private_key", "access_token", "refresh_token", "auth",
})
@classmethod
def _is_sensitive_field_name(cls, name: str) -> bool:
lowered = name.lower()
return lowered in cls._SENSITIVE_NAMES or any(
part in cls._SENSITIVE_NAMES for part in lowered.split("_")
)
RESTRICTED: dict[str, dict[str, Any]] = {
"max_iterations": {"type": int, "min": 1, "max": 100},
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
@@ -123,15 +127,15 @@ class MyTool(Tool):
"context_window_tokens",
})
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
self._runtime_state = runtime_state
def __init__(self, runtime_control: RuntimeControl, modify_allowed: bool = True) -> None:
self._runtime_control = runtime_control
self._modify_allowed = modify_allowed
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
result._runtime_state = self._runtime_state
result._runtime_control = self._runtime_control
result._modify_allowed = self._modify_allowed
return result
@@ -208,9 +212,12 @@ class MyTool(Tool):
# Path resolution
# ------------------------------------------------------------------
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
def _resolve_path(
self,
snapshot: RuntimeSnapshot,
path: str,
) -> tuple[object | None, str | None]:
parts = path.split(".")
obj: Any = self._runtime_state
for part in parts:
if part in self._DENIED_ATTRS or part.startswith("__"):
return None, f"'{part}' is not accessible"
@@ -218,17 +225,13 @@ class MyTool(Tool):
return None, f"'{part}' is not accessible"
if part.lower() in self._SENSITIVE_NAMES:
return None, f"'{part}' is not accessible"
try:
if isinstance(obj, Mapping):
mapping = cast(Mapping[str, Any], obj)
if part in mapping:
obj = mapping[part]
else:
return None, f"'{part}' not found in mapping"
else:
obj = getattr(obj, part)
except (KeyError, AttributeError) as e:
return None, f"'{part}' not found: {e}"
obj: object = snapshot.as_mapping()
for part in parts:
if not _is_string_mapping(obj):
return None, f"'{part}' not found"
if part not in obj:
return None, f"'{part}' not found in mapping"
obj = obj[part]
return obj, None
@staticmethod
@@ -242,20 +245,48 @@ class MyTool(Tool):
# ------------------------------------------------------------------
@staticmethod
def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
elapsed = time.monotonic() - st.started_at
tool_summary = ", ".join(
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
) or "none"
def _format_status(
st: "SubagentStatus | Mapping[str, object]",
indent: str = " ",
) -> str:
if isinstance(st, Mapping):
started_at = st.get("started_at", time.monotonic())
raw_events = st.get("tool_events", [])
phase = st.get("phase", "unknown")
iteration = st.get("iteration", 0)
usage = st.get("usage", {})
error = st.get("error")
stop_reason = st.get("stop_reason")
else:
started_at = st.started_at
raw_events = st.tool_events
phase = st.phase
iteration = st.iteration
usage = st.usage
error = st.error
stop_reason = st.stop_reason
elapsed = time.monotonic() - (
float(started_at) if isinstance(started_at, (int, float)) else time.monotonic()
)
tool_events = cast(list[object], raw_events) if isinstance(raw_events, list) else []
tool_summaries: list[str] = []
for raw_event in tool_events[-5:]:
if not isinstance(raw_event, Mapping):
continue
event = cast(Mapping[str, object], raw_event)
tool_summaries.append(
f"{event.get('name', '?')}({event.get('status', '?')})"
)
tool_summary = ", ".join(tool_summaries) or "none"
lines = [
f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s",
f"{indent}phase: {phase}, iteration: {iteration}, elapsed: {elapsed:.1f}s",
f"{indent}tools: {tool_summary}",
f"{indent}usage: {st.usage or 'n/a'}",
f"{indent}usage: {usage or 'n/a'}",
]
if st.error:
lines.append(f"{indent}error: {st.error}")
if st.stop_reason:
lines.append(f"{indent}stop_reason: {st.stop_reason}")
if error:
lines.append(f"{indent}error: {error}")
if stop_reason:
lines.append(f"{indent}stop_reason: {stop_reason}")
return "\n".join(lines)
@staticmethod
@@ -264,29 +295,38 @@ class MyTool(Tool):
header = f"Subagent [{val.task_id}] '{val.label}'"
detail = MyTool._format_status(val, " ")
return f"{header}\n task: {val.task_description}\n{detail}"
# SubagentManager: delegate to its _task_statuses dict
task_statuses = getattr(val, "_task_statuses", None)
if isinstance(task_statuses, dict):
return MyTool._format_value(task_statuses, key)
if _is_subagent_status_snapshot(val):
header = f"Subagent [{val['task_id']}] '{val['label']}'"
detail = MyTool._format_status(val, " ")
return f"{header}\n task: {val['task_description']}\n{detail}"
if isinstance(val, Mapping):
mapping = cast(Mapping[object, object], val)
else:
mapping = None
if mapping and set(mapping) == {"_task_statuses"}:
task_statuses = mapping["_task_statuses"]
if isinstance(task_statuses, Mapping):
return MyTool._format_value(task_statuses, key)
if (
mapping
and _is_subagent_status(next(iter(mapping.values())))
and (
_is_subagent_status(next(iter(mapping.values())))
or _is_subagent_status_snapshot(next(iter(mapping.values())))
)
):
status_mapping: Mapping[object, SubagentStatus] = cast(Any, mapping)
prefix = f"{key}: " if key else ""
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
for tid, st in status_mapping.items():
detail = MyTool._format_status(st, " ")
lines.append(f" [{tid}] '{st.label}'\n{detail}")
lines = [f"{prefix}{len(mapping)} subagent(s):"]
for tid, st in mapping.items():
if _is_subagent_status(st):
detail = MyTool._format_status(st, " ")
label = st.label
elif _is_subagent_status_snapshot(st):
detail = MyTool._format_status(st, " ")
label = st.get("label", "?")
else:
continue
lines.append(f" [{tid}] '{label}'\n{detail}")
return "\n".join(lines)
dynamic_value = cast(Any, val)
if hasattr(dynamic_value, "tool_names"):
tool_names: Any = getattr(dynamic_value, "tool_names")
return f"tools: {len(tool_names)} registered — {tool_names}"
# Scalar types — repr is fine
if isinstance(val, (str, int, float, bool, type(None))):
r = repr(val)
@@ -311,32 +351,6 @@ class MyTool(Tool):
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
r = repr(sequence)
return f"{key}: {r}" if key else r
# Complex object — small Pydantic models: show values; others: show field names for navigation
value_type = type(cast(object, val))
cls_name = value_type.__name__
model_fields = cast(object, getattr(value_type, "model_fields", None))
if isinstance(model_fields, Mapping) and model_fields:
fields = list(cast(Mapping[str, object], model_fields).keys())
if len(fields) <= 8:
# Small config objects: show field=value pairs
pairs: list[str] = []
for f in fields:
fv = getattr(val, f, "?")
if MyTool._is_sensitive_field_name(f):
continue
if isinstance(fv, (str, int, float, bool, type(None))):
pairs.append(f"{f}={fv!r}")
else:
pairs.append(f"{f}=<{type(fv).__name__}>")
preview = ", ".join(pairs)
return f"{key}: {preview}" if key else preview
else:
attributes = cast(dict[str, Any], getattr(val, "__dict__", {}))
fields = [name for name in attributes if not name.startswith("__")]
if fields:
preview = ", ".join(str(f) for f in fields[:20])
suffix = ", ..." if len(fields) > 20 else ""
return f"{key}: <{cls_name}> [{preview}{suffix}]" if key else f"<{cls_name}> [{preview}{suffix}]"
r = repr(val)
return f"{key}: {r}" if key else r
@@ -366,7 +380,12 @@ class MyTool(Tool):
runtime = request_ctx.runtime if request_ctx is not None else None
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
return False, None
return True, getattr(runtime, key)
values: dict[str, object] = {
"model": runtime.model,
"model_preset": runtime.model_preset,
"context_window_tokens": runtime.context_window_tokens,
}
return True, values[key]
def _inspect(self, key: str | None) -> str:
if not key:
@@ -375,62 +394,64 @@ class MyTool(Tool):
request_ctx = current_request_context()
if request_ctx is None:
return ToolResult.error("Error: current request context is unavailable")
request_values: dict[str, str | None] = {
"channel": request_ctx.channel,
"chat_id": request_ctx.chat_id,
"sender_id": request_ctx.sender_id,
}
if key == "request":
return self._format_value(
{field: getattr(request_ctx, field) for field in self._REQUEST_FIELDS},
key,
)
return self._format_value(request_values, key)
field = key.removeprefix("request.")
if field not in self._REQUEST_FIELDS:
return ToolResult.error(f"Error: '{key}' not found")
return self._format_value(getattr(request_ctx, field), key)
return self._format_value(request_values[field], key)
if "." not in key:
found, value = self._current_runtime_value(key)
if found:
return self._format_value(value, key)
snapshot = self._runtime_control.snapshot()
top = key.split(".")[0]
if top in self._DENIED_ATTRS or top.startswith("__"):
return ToolResult.error(f"Error: '{top}' is not accessible")
obj, err = self._resolve_path(key)
obj, err = self._resolve_path(snapshot, key)
if err:
# "scratchpad" alias for _runtime_vars
if key == "scratchpad":
rv = self._runtime_state._runtime_vars
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
# Fallback: check _runtime_vars for simple keys stored by modify
if "." not in key and key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key)
return (
self._format_value(snapshot.scratchpad, "scratchpad")
if snapshot.scratchpad
else "scratchpad is empty"
)
if "." not in key and key in snapshot.scratchpad:
return self._format_value(snapshot.scratchpad[key], key)
return ToolResult.error(f"Error: {err}")
# Guard against mock auto-generated attributes
if "." not in key and not _has_real_attr(self._runtime_state, key):
if key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key)
return ToolResult.error(f"Error: '{key}' not found")
return self._format_value(obj, key)
def _inspect_all(self) -> str:
state = self._runtime_state
snapshot = self._runtime_control.snapshot()
values = snapshot.as_mapping()
parts: list[str] = []
# RESTRICTED keys
for k in self.RESTRICTED:
found, value = self._current_runtime_value(k)
parts.append(self._format_value(value if found else getattr(state, k, None), k))
parts.append(self._format_value(value if found else values[k], k))
found, value = self._current_runtime_value("model_preset")
parts.append(self._format_value(
value if found else state.model_preset,
value if found else snapshot.model_preset,
"model_preset",
))
# Other useful top-level keys shown in description
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "workspace_sandbox", "subagents"):
if _has_real_attr(state, k):
parts.append(self._format_value(getattr(state, k, None), k))
# Token usage
usage = state._last_usage
if usage:
parts.append(self._format_value(usage, "_last_usage"))
rv = state._runtime_vars
if rv:
parts.append(self._format_value(rv, "scratchpad"))
for k in (
"workspace",
"provider_retry_mode",
"max_tool_result_chars",
"_current_iteration",
"web_config",
"exec_config",
"subagents",
):
parts.append(self._format_value(values[k], k))
if snapshot.last_usage:
parts.append(self._format_value(snapshot.last_usage, "_last_usage"))
if snapshot.scratchpad:
parts.append(self._format_value(snapshot.scratchpad, "scratchpad"))
return "\n".join(parts)
# -- modify --
@@ -454,48 +475,49 @@ class MyTool(Tool):
if leaf.lower() in self._SENSITIVE_NAMES:
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
return ToolResult.error(f"Error: '{leaf}' is not accessible")
parent, err = self._resolve_path(parent_path)
snapshot = self._runtime_control.snapshot()
_parent, err = self._resolve_path(snapshot, parent_path)
if err:
return ToolResult.error(f"Error: {err}")
if isinstance(parent, dict):
parent[leaf] = value
else:
setattr(parent, leaf, value)
self._audit("modify", f"{key} = {value!r}")
return f"Set {key} = {value!r}"
self._audit("modify", f"READ_ONLY {key}")
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
if key == "model_preset":
return self._modify_model_preset(value)
if key in self.RESTRICTED:
return self._modify_restricted(key, value)
return self._modify_free(key, value)
if key in RUNTIME_COMMAND_KEYS:
return self._modify_runtime_setting(key, value)
if key in RUNTIME_SNAPSHOT_KEYS:
self._audit("modify", f"READ_ONLY {key}")
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
return self._modify_scratchpad(key, value)
def _modify_model_preset(self, value: Any) -> str:
if not isinstance(value, str) or not value.strip():
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
name = value.strip()
session_key = current_request_session_key()
old = self._runtime_control.snapshot().model_preset
try:
runtime = self._runtime_control.set_model_preset(
name,
session_key=session_key,
)
except (KeyError, ValueError) as exc:
message = str(exc.args[0]) if exc.args else str(exc)
punctuation = "" if message.endswith((".", "!", "?")) else "."
return ToolResult.error(f"Error: {message}{punctuation}")
if session_key:
try:
runtime = self._runtime_state.set_session_model_preset(
session_key,
name,
)
except (KeyError, ValueError) as exc:
message = str(exc.args[0]) if exc.args else str(exc)
punctuation = "" if message.endswith((".", "!", "?")) else "."
return ToolResult.error(f"Error: {message}{punctuation}")
self._audit("modify", f"model_preset = {name!r}")
return (
f"Set model_preset = {name!r} for the next turn; "
f"model will be {runtime.model!r}; "
f"context_window_tokens will be {runtime.context_window_tokens!r}"
)
result = self._modify_free("model_preset", name)
if isinstance(result, ToolResult) and result.is_error:
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
self._audit("modify", f"model_preset: {old!r} -> {name!r}")
return (
f"{result}; model is now {self._runtime_state.model!r}; "
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
f"Set model_preset = {name!r} (was {old!r}); model is now {runtime.model!r}; "
f"context_window_tokens is now {runtime.context_window_tokens!r}"
)
def _modify_restricted(self, key: str, value: Any) -> str:
@@ -508,7 +530,7 @@ class MyTool(Tool):
value = expected(value)
except (ValueError, TypeError):
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
old = getattr(self._runtime_state, key)
old = self._runtime_control.snapshot().as_mapping()[key]
if "min" in spec and value < spec["min"]:
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
if "max" in spec and value > spec["max"]:
@@ -521,41 +543,46 @@ class MyTool(Tool):
"during an active session; use a configured model_preset"
)
if key == "model":
self._runtime_state.set_runtime_model(cast(str, value))
self._runtime_control.set_model(cast(str, value))
elif key == "context_window_tokens":
self._runtime_state.set_runtime_context_window(cast(int, value))
self._runtime_control.set_context_window_tokens(cast(int, value))
else:
setattr(self._runtime_state, key, value)
if key == "max_iterations" and hasattr(
self._runtime_state,
"_sync_subagent_runtime_limits",
):
self._runtime_state._sync_subagent_runtime_limits()
self._runtime_control.set_max_iterations(cast(int, value))
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
def _modify_free(self, key: str, value: Any) -> str:
if _has_real_attr(self._runtime_state, key):
old = getattr(self._runtime_state, key)
if isinstance(old, (str, int, float, bool)):
old_t: type[Any] = type(old)
new_t = cast(type[Any], type(value))
if old_t is float and new_t is int:
pass # int → float coercion allowed
elif old_t is not new_t:
self._audit(
"modify",
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
)
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
try:
setattr(self._runtime_state, key, value)
except (ValueError, KeyError) as e:
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
self._audit("modify", f"REJECTED {key}: {message}")
return ToolResult.error(f"Error: {message}")
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
def _modify_runtime_setting(self, key: str, value: Any) -> str:
old = self._runtime_control.snapshot().as_mapping()[key]
if key == "workspace":
if not isinstance(value, str):
return ToolResult.error(
f"Error: 'workspace' expects str, got {type(value).__name__}"
)
self._runtime_control.set_workspace_display(value)
self._audit("modify", f"workspace: {old!r} -> {value!r}")
return f"Set workspace = {value!r} (was {old!r})"
old_t = type(old)
new_t = cast(type[Any], type(value))
if old_t is float and new_t is int:
pass
elif old_t is not new_t:
self._audit(
"modify",
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
)
return ToolResult.error(
f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
)
if key == "provider_retry_mode":
self._runtime_control.set_provider_retry_mode(cast(str, value))
elif key == "max_tool_result_chars":
self._runtime_control.set_max_tool_result_chars(cast(int, value))
else:
raise AssertionError(f"Unhandled runtime command: {key}")
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
def _modify_scratchpad(self, key: str, value: Any) -> str:
if callable(value):
self._audit("modify", f"REJECTED callable {key}")
return ToolResult.error("Error: cannot store callable values")
@@ -563,12 +590,16 @@ class MyTool(Tool):
if err:
self._audit("modify", f"REJECTED {key}: {err}")
return ToolResult.error(f"Error: {err}")
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
try:
self._runtime_control.set_scratchpad(
key,
cast(JsonValue, value),
max_keys=self._MAX_RUNTIME_KEYS,
)
except ValueError as exc:
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
old = self._runtime_state._runtime_vars.get(key)
self._runtime_state._runtime_vars[key] = value
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
return ToolResult.error(f"Error: {exc}. Remove unused keys first.")
self._audit("modify", f"scratchpad.{key} = {value!r}")
return f"Set scratchpad.{key} = {value!r}"
@classmethod