"""MyTool: runtime state inspection and configuration for the agent loop.""" # Tool.execute accepts heterogeneous schemas. # pyright: reportIncompatibleMethodOverride=false from __future__ import annotations import time from collections.abc import Mapping from typing import TYPE_CHECKING, Any, TypeGuard, cast 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_control import ( RUNTIME_COMMAND_KEYS, RUNTIME_SNAPSHOT_KEYS, JsonValue, RuntimeControl, RuntimeSnapshot, ) from nanobot.config_base import Base if TYPE_CHECKING: from nanobot.agent.subagent import SubagentStatus from nanobot.agent.tools.context import ToolContext class MyToolConfig(Base): """Self-inspection tool configuration.""" enable: bool = True allow_set: bool = 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.""" config_key = "my" @classmethod def config_cls(cls): return MyToolConfig @classmethod def enabled(cls, ctx: ToolContext) -> bool: return ctx.runtime_control is not None and ctx.config.my.enable @classmethod def create(cls, ctx: ToolContext) -> Tool: if ctx.runtime_control is None: raise RuntimeError("MyTool requires a runtime control capability") return cls( runtime_control=ctx.runtime_control, modify_allowed=ctx.config.my.allow_set, ) BLOCKED = frozenset({ # Core infrastructure "bus", "provider", "runtime_resolver", "_running", "tools", # Config management "_runtime_vars", # Subsystems "runner", "sessions", "consolidator", "dream", "auto_compact", "context", "commands", # Sensitive runtime state (credentials, message routing, task tracking) "_pending_queues", "_session_locks", "_active_tasks", "_background_tasks", # Security boundaries (inspect + modify both blocked) "restrict_to_workspace", "channels_config", "_concurrency_gate", "_unified_session", "_extra_hooks", "_hook_factories", }) READ_ONLY = frozenset({ "subagents", # observable but replacing it would break the system "tool_names", "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 "workspace_sandbox", # read-only view of workspace enforcement level "request", # current message routing metadata }) _REQUEST_FIELDS = ("channel", "chat_id", "sender_id") _DENIED_ATTRS = frozenset({ "__class__", "__dict__", "__bases__", "__subclasses__", "__mro__", "__init__", "__new__", "__reduce__", "__getstate__", "__setstate__", "__del__", "__call__", "__getattr__", "__setattr__", "__delattr__", "__code__", "__globals__", "func_globals", "func_code", "__wrapped__", "__closure__", }) # Sub-field names that are sensitive regardless of parent path _SENSITIVE_NAMES = frozenset({ "api_key", "secret", "password", "token", "credential", "private_key", "access_token", "refresh_token", "auth", }) 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}, "model": {"type": str, "min_len": 1}, } _MAX_RUNTIME_KEYS = 64 _MODEL_RUNTIME_FIELDS = frozenset({ "model", "model_preset", "context_window_tokens", }) 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_control = self._runtime_control result._modify_allowed = self._modify_allowed return result @property def name(self) -> str: return "my" @property def description(self) -> str: base = ( "Check and set your own runtime state.\n" "Actions: check, set.\n" "- check (no key): full config overview — start here.\n" "- check (key): drill into a value. Dot-paths allowed " "(e.g. 'web_config.enable').\n" "- set (key, value): change config or store notes in your scratchpad. " "Scratchpad keys persist across turns but not restarts.\n" "Current routing metadata is available read-only via request.channel, " "request.chat_id, and request.sender_id.\n" "Use model_preset for session-scoped model or context changes; direct " "model/context_window_tokens writes are disabled during active sessions.\n" "Note: web_config and exec_config are readable but read-only.\n" "\n" "When to use:\n" "- User asks about your model or settings → check that key.\n" "- User asks to switch to a named model preset → set model_preset to that preset name.\n" "- A tool fails or behaves unexpectedly → check the related config to diagnose.\n" "- User asks you to remember a preference for this session → set to store it in your scratchpad.\n" "- About to start a large task → check context_window_tokens and max_iterations first." ) if not self._modify_allowed: base += "\nREAD-ONLY MODE: set is disabled." else: base += ( "\nIMPORTANT: Before setting state, predict the potential impact. " "If the operation could cause crashes or instability " "(e.g. changing model), warn the user first." ) return base @property def parameters(self) -> dict[str, Any]: return { "type": "object", "properties": { "action": { "type": "string", "enum": ["check", "set"], "description": "Action to perform", }, "key": { "type": "string", "description": "Dot-path for check/set. Examples: 'max_iterations', 'workspace', 'provider_retry_mode'. " "Use 'request.channel', 'request.chat_id', or 'request.sender_id' for current routing metadata. " "Use 'model_preset' to switch named model presets. For check without key, shows all config values.", }, "value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model/model_preset)."}, }, "required": ["action"], } def _audit(self, action: str, detail: str) -> None: ctx = current_request_context() session = ( ctx.session_key or f"{ctx.channel}:{ctx.chat_id}" if ctx is not None and ctx.channel else "unknown" ) logger.info("self.{} | {} | session:{}", action, detail, session) # ------------------------------------------------------------------ # Path resolution # ------------------------------------------------------------------ def _resolve_path( self, snapshot: RuntimeSnapshot, path: str, ) -> tuple[object | None, str | None]: parts = path.split(".") for part in parts: if part in self._DENIED_ATTRS or part.startswith("__"): return None, f"'{part}' is not accessible" if part in self.BLOCKED: return None, f"'{part}' is not accessible" if part.lower() in self._SENSITIVE_NAMES: return None, f"'{part}' is not accessible" 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 def _validate_key(key: str | None, label: str = "key") -> str | None: if not key or not key.strip(): return ToolResult.error(f"Error: '{label}' cannot be empty or whitespace") return None # ------------------------------------------------------------------ # Smart formatting # ------------------------------------------------------------------ @staticmethod 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: {phase}, iteration: {iteration}, elapsed: {elapsed:.1f}s", f"{indent}tools: {tool_summary}", f"{indent}usage: {usage or 'n/a'}", ] if error: lines.append(f"{indent}error: {error}") if stop_reason: lines.append(f"{indent}stop_reason: {stop_reason}") return "\n".join(lines) @staticmethod def _format_value(val: Any, key: str = "") -> str: if _is_subagent_status(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 _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()))) or _is_subagent_status_snapshot(next(iter(mapping.values()))) ) ): prefix = f"{key}: " if key else "" 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) # Scalar types — repr is fine if isinstance(val, (str, int, float, bool, type(None))): r = repr(val) return f"{key}: {r}" if key else r # Mapping — small: show content; large: show keys for dot-path navigation if isinstance(val, Mapping): value_mapping = cast(Mapping[object, object], val) ks = list(value_mapping.keys()) if not ks: return f"{key}: {{}}" if key else "{}" if len(ks) <= 5: r = repr(value_mapping) if len(r) <= 200: return f"{key}: {r}" if key else r preview = ", ".join(str(k) for k in ks[:15]) suffix = ", ..." if len(ks) > 15 else "" return f"{key}: {{{preview}{suffix}}}" if key else f"{{{preview}{suffix}}}" # List/tuple — count for large, repr for small if isinstance(val, (list, tuple)): sequence = cast(list[object] | tuple[object, ...], val) if len(sequence) > 20: return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]" r = repr(sequence) return f"{key}: {r}" if key else r r = repr(val) return f"{key}: {r}" if key else r # ------------------------------------------------------------------ # Action dispatch # ------------------------------------------------------------------ async def execute( self, action: str, key: str | None = None, value: Any = None, **_kwargs: Any, ) -> str: if action in ("inspect", "check"): return self._inspect(key) if not self._modify_allowed: return ToolResult.error("Error: set is disabled (tools.my.allow_set is false)") if action in ("modify", "set"): return self._modify(key, value) return f"Unknown action: {action}" # -- inspect -- def _current_runtime_value(self, key: str) -> tuple[bool, Any]: request_ctx = current_request_context() 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 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: return self._inspect_all() if key == "request" or key.startswith("request."): 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(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(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(snapshot, key) if err: if key == "scratchpad": 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}") return self._format_value(obj, key) def _inspect_all(self) -> str: snapshot = self._runtime_control.snapshot() values = snapshot.as_mapping() parts: list[str] = [] for k in self.RESTRICTED: found, value = self._current_runtime_value(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 snapshot.model_preset, "model_preset", )) for k in ( "workspace", "provider_retry_mode", "max_tool_result_chars", "web_config", "exec_config", "subagents", ): parts.append(self._format_value(values[k], k)) if snapshot.scratchpad: parts.append(self._format_value(snapshot.scratchpad, "scratchpad")) return "\n".join(parts) # -- modify -- def _modify(self, key: str | None, value: Any) -> str: if err := self._validate_key(key): return err key = cast(str, key) top = key.split(".")[0] if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES: self._audit("modify", f"BLOCKED {key}") return ToolResult.error(f"Error: '{key}' is protected and cannot be modified") if top in self.READ_ONLY: self._audit("modify", f"READ_ONLY {key}") return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified") if "." in key: parent_path, leaf = key.rsplit(".", 1) if leaf in self._DENIED_ATTRS or leaf.startswith("__"): self._audit("modify", f"BLOCKED leaf '{leaf}'") return ToolResult.error(f"Error: '{leaf}' is not accessible") if leaf.lower() in self._SENSITIVE_NAMES: self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'") return ToolResult.error(f"Error: '{leaf}' is not accessible") snapshot = self._runtime_control.snapshot() _parent, err = self._resolve_path(snapshot, parent_path) if err: return ToolResult.error(f"Error: {err}") 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) 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: 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}" ) self._audit("modify", f"model_preset: {old!r} -> {name!r}") return ( 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: spec = self.RESTRICTED[key] expected = cast(type[Any], spec["type"]) if expected is int and isinstance(value, bool): return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got bool") if not isinstance(value, expected): try: value = expected(value) except (ValueError, TypeError): return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}") 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"]: return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}") if "min_len" in spec and len(str(value)) < spec["min_len"]: return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters") if key in {"model", "context_window_tokens"} and current_request_session_key(): return ToolResult.error( f"Error: direct '{key}' changes are instance-wide and disabled " "during an active session; use a configured model_preset" ) if key == "model": self._runtime_control.set_model(cast(str, value)) elif key == "context_window_tokens": self._runtime_control.set_context_window_tokens(cast(int, value)) else: 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_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") err = self._validate_json_safe(value) if err: self._audit("modify", f"REJECTED {key}: {err}") return ToolResult.error(f"Error: {err}") 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: {exc}. Remove unused keys first.") self._audit("modify", f"scratchpad.{key} = {value!r}") return f"Set scratchpad.{key} = {value!r}" @classmethod def _validate_json_safe(cls, value: Any, depth: int = 0) -> str | None: if depth > 10: return "value nesting too deep (max 10 levels)" if isinstance(value, (str, int, float, bool, type(None))): return None if isinstance(value, list): for i, item in enumerate(cast(list[Any], value)): if err := cls._validate_json_safe(item, depth + 1): return f"list[{i}] contains {err}" return None if isinstance(value, dict): for k, v in cast(dict[Any, Any], value).items(): if not isinstance(k, str): return f"dict key must be str, got {type(k).__name__}" if err := cls._validate_json_safe(v, depth + 1): return f"dict key '{k}' contains {err}" return None return f"unsupported type {type(value).__name__}"