| 1 | """MyTool: runtime state inspection and configuration for the agent loop.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import time |
| 6 | from typing import TYPE_CHECKING, Any |
| 7 | |
| 8 | from loguru import logger |
| 9 | |
| 10 | from nanobot.agent.subagent import SubagentStatus |
| 11 | from nanobot.agent.tools.base import Tool |
| 12 | |
| 13 | if TYPE_CHECKING: |
| 14 | from nanobot.agent.loop import AgentLoop |
| 15 | |
| 16 | |
| 17 | def _has_real_attr(obj: Any, key: str) -> bool: |
| 18 | """Check if obj has a real (explicitly set) attribute, not auto-generated by mock.""" |
| 19 | if isinstance(obj, dict): |
| 20 | return key in obj |
| 21 | d = getattr(obj, "__dict__", None) |
| 22 | if d is not None and key in d: |
| 23 | return True |
| 24 | for cls in type(obj).__mro__: |
| 25 | if key in cls.__dict__: |
| 26 | return True |
| 27 | return False |
| 28 | |
| 29 | |
| 30 | class MyTool(Tool): |
| 31 | """Check and set the agent loop's runtime configuration.""" |
| 32 | |
| 33 | BLOCKED = frozenset({ |
| 34 | # Core infrastructure |
| 35 | "bus", "provider", "_running", "tools", |
| 36 | # Config management |
| 37 | "_runtime_vars", |
| 38 | # Subsystems |
| 39 | "runner", "sessions", "consolidator", |
| 40 | "dream", "auto_compact", "context", "commands", |
| 41 | # Sensitive runtime state (credentials, message routing, task tracking) |
| 42 | "_mcp_servers", "_mcp_stacks", "_pending_queues", |
| 43 | "_session_locks", "_active_tasks", "_background_tasks", |
| 44 | # Security boundaries (inspect + modify both blocked) |
| 45 | "restrict_to_workspace", "channels_config", |
| 46 | "_concurrency_gate", "_unified_session", "_extra_hooks", |
| 47 | }) |
| 48 | |
| 49 | READ_ONLY = frozenset({ |
| 50 | "subagents", # observable but replacing it would break the system |
| 51 | "_current_iteration", # updated by runner only |
| 52 | "exec_config", # inspect allowed (e.g. check sandbox), modify blocked |
| 53 | "web_config", # inspect allowed (e.g. check enable), modify blocked |
| 54 | }) |
| 55 | |
| 56 | _DENIED_ATTRS = frozenset({ |
| 57 | "__class__", "__dict__", "__bases__", "__subclasses__", "__mro__", |
| 58 | "__init__", "__new__", "__reduce__", "__getstate__", "__setstate__", |
| 59 | "__del__", "__call__", "__getattr__", "__setattr__", "__delattr__", |
| 60 | "__code__", "__globals__", "func_globals", "func_code", |
| 61 | "__wrapped__", "__closure__", |
| 62 | }) |
| 63 | |
| 64 | # Sub-field names that are sensitive regardless of parent path |
| 65 | _SENSITIVE_NAMES = frozenset({ |
| 66 | "api_key", "secret", "password", "token", "credential", |
| 67 | "private_key", "access_token", "refresh_token", "auth", |
| 68 | }) |
| 69 | |
| 70 | @classmethod |
| 71 | def _is_sensitive_field_name(cls, name: str) -> bool: |
| 72 | lowered = name.lower() |
| 73 | return lowered in cls._SENSITIVE_NAMES or any( |
| 74 | part in cls._SENSITIVE_NAMES for part in lowered.split("_") |
| 75 | ) |
| 76 | |
| 77 | RESTRICTED: dict[str, dict[str, Any]] = { |
| 78 | "max_iterations": {"type": int, "min": 1, "max": 100}, |
| 79 | "context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000}, |
| 80 | "model": {"type": str, "min_len": 1}, |
| 81 | } |
| 82 | |
| 83 | _MAX_RUNTIME_KEYS = 64 |
| 84 | |
| 85 | def __init__(self, loop: AgentLoop, modify_allowed: bool = True) -> None: |
| 86 | self._loop = loop |
| 87 | self._modify_allowed = modify_allowed |
| 88 | self._channel = "" |
| 89 | self._chat_id = "" |
| 90 | |
| 91 | def __deepcopy__(self, memo: dict[int, Any]) -> MyTool: |
| 92 | cls = self.__class__ |
| 93 | result = cls.__new__(cls) |
| 94 | memo[id(self)] = result |
| 95 | result._loop = self._loop |
| 96 | result._modify_allowed = self._modify_allowed |
| 97 | result._channel = self._channel |
| 98 | result._chat_id = self._chat_id |
| 99 | return result |
| 100 | |
| 101 | def set_context(self, channel: str, chat_id: str) -> None: |
| 102 | self._channel = channel |
| 103 | self._chat_id = chat_id |
| 104 | |
| 105 | @property |
| 106 | def name(self) -> str: |
| 107 | return "my" |
| 108 | |
| 109 | @property |
| 110 | def description(self) -> str: |
| 111 | base = ( |
| 112 | "Check and set your own runtime state.\n" |
| 113 | "Actions: check, set.\n" |
| 114 | "- check (no key): full config overview — start here.\n" |
| 115 | "- check (key): drill into a value. Dot-paths allowed " |
| 116 | "(e.g. '_last_usage.prompt_tokens', 'web_config.enable').\n" |
| 117 | "- set (key, value): change config or store notes in your scratchpad. " |
| 118 | "Scratchpad keys persist across turns but not restarts.\n" |
| 119 | "Key values: _current_iteration (current progress), " |
| 120 | "max_iterations - _current_iteration = remaining iterations.\n" |
| 121 | "Note: web_config and exec_config are readable but read-only.\n" |
| 122 | "\n" |
| 123 | "When to use:\n" |
| 124 | "- User asks about your model, settings, or token usage → check that key.\n" |
| 125 | "- A tool fails or behaves unexpectedly → check the related config to diagnose.\n" |
| 126 | "- User asks you to remember a preference for this session → set to store it in your scratchpad.\n" |
| 127 | "- About to start a large task → check context_window_tokens and max_iterations first." |
| 128 | ) |
| 129 | if not self._modify_allowed: |
| 130 | base += "\nREAD-ONLY MODE: set is disabled." |
| 131 | else: |
| 132 | base += ( |
| 133 | "\nIMPORTANT: Before setting state, predict the potential impact. " |
| 134 | "If the operation could cause crashes or instability " |
| 135 | "(e.g. changing model), warn the user first." |
| 136 | ) |
| 137 | return base |
| 138 | |
| 139 | @property |
| 140 | def parameters(self) -> dict[str, Any]: |
| 141 | return { |
| 142 | "type": "object", |
| 143 | "properties": { |
| 144 | "action": { |
| 145 | "type": "string", |
| 146 | "enum": ["check", "set"], |
| 147 | "description": "Action to perform", |
| 148 | }, |
| 149 | "key": { |
| 150 | "type": "string", |
| 151 | "description": "Dot-path for check/set. Examples: 'max_iterations', 'workspace', 'provider_retry_mode'. " |
| 152 | "For check without key, shows all config values.", |
| 153 | }, |
| 154 | "value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model)."}, |
| 155 | }, |
| 156 | "required": ["action"], |
| 157 | } |
| 158 | |
| 159 | def _audit(self, action: str, detail: str) -> None: |
| 160 | session = f"{self._channel}:{self._chat_id}" if self._channel else "unknown" |
| 161 | logger.info("self.{} | {} | session:{}", action, detail, session) |
| 162 | |
| 163 | # ------------------------------------------------------------------ |
| 164 | # Path resolution |
| 165 | # ------------------------------------------------------------------ |
| 166 | |
| 167 | def _resolve_path(self, path: str) -> tuple[Any, str | None]: |
| 168 | parts = path.split(".") |
| 169 | obj = self._loop |
| 170 | for part in parts: |
| 171 | if part in self._DENIED_ATTRS or part.startswith("__"): |
| 172 | return None, f"'{part}' is not accessible" |
| 173 | if part in self.BLOCKED: |
| 174 | return None, f"'{part}' is not accessible" |
| 175 | if part.lower() in self._SENSITIVE_NAMES: |
| 176 | return None, f"'{part}' is not accessible" |
| 177 | try: |
| 178 | if isinstance(obj, dict): |
| 179 | if part in obj: |
| 180 | obj = obj[part] |
| 181 | else: |
| 182 | return None, f"'{part}' not found in dict" |
| 183 | else: |
| 184 | obj = getattr(obj, part) |
| 185 | except (KeyError, AttributeError) as e: |
| 186 | return None, f"'{part}' not found: {e}" |
| 187 | return obj, None |
| 188 | |
| 189 | @staticmethod |
| 190 | def _validate_key(key: str | None, label: str = "key") -> str | None: |
| 191 | if not key or not key.strip(): |
| 192 | return f"Error: '{label}' cannot be empty or whitespace" |
| 193 | return None |
| 194 | |
| 195 | # ------------------------------------------------------------------ |
| 196 | # Smart formatting |
| 197 | # ------------------------------------------------------------------ |
| 198 | |
| 199 | @staticmethod |
| 200 | def _format_status(st: SubagentStatus, indent: str = " ") -> str: |
| 201 | elapsed = time.monotonic() - st.started_at |
| 202 | tool_summary = ", ".join( |
| 203 | f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:] |
| 204 | ) or "none" |
| 205 | lines = [ |
| 206 | f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s", |
| 207 | f"{indent}tools: {tool_summary}", |
| 208 | f"{indent}usage: {st.usage or 'n/a'}", |
| 209 | ] |
| 210 | if st.error: |
| 211 | lines.append(f"{indent}error: {st.error}") |
| 212 | if st.stop_reason: |
| 213 | lines.append(f"{indent}stop_reason: {st.stop_reason}") |
| 214 | return "\n".join(lines) |
| 215 | |
| 216 | @staticmethod |
| 217 | def _format_value(val: Any, key: str = "") -> str: |
| 218 | if isinstance(val, SubagentStatus): |
| 219 | header = f"Subagent [{val.task_id}] '{val.label}'" |
| 220 | detail = MyTool._format_status(val, " ") |
| 221 | return f"{header}\n task: {val.task_description}\n{detail}" |
| 222 | # SubagentManager: delegate to its _task_statuses dict |
| 223 | if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict): |
| 224 | return MyTool._format_value(val._task_statuses, key) |
| 225 | if isinstance(val, dict) and val and isinstance(next(iter(val.values())), SubagentStatus): |
| 226 | prefix = f"{key}: " if key else "" |
| 227 | lines = [f"{prefix}{len(val)} subagent(s):"] |
| 228 | for tid, st in val.items(): |
| 229 | detail = MyTool._format_status(st, " ") |
| 230 | lines.append(f" [{tid}] '{st.label}'\n{detail}") |
| 231 | return "\n".join(lines) |
| 232 | if hasattr(val, "tool_names"): |
| 233 | return f"tools: {len(val.tool_names)} registered — {val.tool_names}" |
| 234 | # Scalar types — repr is fine |
| 235 | if isinstance(val, (str, int, float, bool, type(None))): |
| 236 | r = repr(val) |
| 237 | return f"{key}: {r}" if key else r |
| 238 | # Dict — small: show content; large: show keys for dot-path navigation |
| 239 | if isinstance(val, dict): |
| 240 | ks = list(val.keys()) |
| 241 | if not ks: |
| 242 | return f"{key}: {{}}" if key else "{}" |
| 243 | if len(ks) <= 5: |
| 244 | r = repr(val) |
| 245 | if len(r) <= 200: |
| 246 | return f"{key}: {r}" if key else r |
| 247 | preview = ", ".join(str(k) for k in ks[:15]) |
| 248 | suffix = ", ..." if len(ks) > 15 else "" |
| 249 | return f"{key}: {{{preview}{suffix}}}" if key else f"{{{preview}{suffix}}}" |
| 250 | # List/tuple — count for large, repr for small |
| 251 | if isinstance(val, (list, tuple)): |
| 252 | if len(val) > 20: |
| 253 | return f"{key}: [{len(val)} items]" if key else f"[{len(val)} items]" |
| 254 | r = repr(val) |
| 255 | return f"{key}: {r}" if key else r |
| 256 | # Complex object — small Pydantic models: show values; others: show field names for navigation |
| 257 | cls_name = type(val).__name__ |
| 258 | model_fields = getattr(type(val), "model_fields", None) |
| 259 | if model_fields: |
| 260 | fields = list(model_fields.keys()) |
| 261 | if len(fields) <= 8: |
| 262 | # Small config objects: show field=value pairs |
| 263 | pairs = [] |
| 264 | for f in fields: |
| 265 | fv = getattr(val, f, "?") |
| 266 | if MyTool._is_sensitive_field_name(f): |
| 267 | continue |
| 268 | if isinstance(fv, (str, int, float, bool, type(None))): |
| 269 | pairs.append(f"{f}={fv!r}") |
| 270 | else: |
| 271 | pairs.append(f"{f}=<{type(fv).__name__}>") |
| 272 | preview = ", ".join(pairs) |
| 273 | return f"{key}: {preview}" if key else preview |
| 274 | else: |
| 275 | fields = [a for a in getattr(val, "__dict__", {}) if not a.startswith("__")] |
| 276 | if fields: |
| 277 | preview = ", ".join(str(f) for f in fields[:20]) |
| 278 | suffix = ", ..." if len(fields) > 20 else "" |
| 279 | return f"{key}: <{cls_name}> [{preview}{suffix}]" if key else f"<{cls_name}> [{preview}{suffix}]" |
| 280 | r = repr(val) |
| 281 | return f"{key}: {r}" if key else r |
| 282 | |
| 283 | # ------------------------------------------------------------------ |
| 284 | # Action dispatch |
| 285 | # ------------------------------------------------------------------ |
| 286 | |
| 287 | async def execute( |
| 288 | self, |
| 289 | action: str, |
| 290 | key: str | None = None, |
| 291 | value: Any = None, |
| 292 | **_kwargs: Any, |
| 293 | ) -> str: |
| 294 | if action in ("inspect", "check"): |
| 295 | return self._inspect(key) |
| 296 | if not self._modify_allowed: |
| 297 | return "Error: set is disabled (tools.my.allow_set is false)" |
| 298 | if action in ("modify", "set"): |
| 299 | return self._modify(key, value) |
| 300 | return f"Unknown action: {action}" |
| 301 | |
| 302 | # -- inspect -- |
| 303 | |
| 304 | def _inspect(self, key: str | None) -> str: |
| 305 | if not key: |
| 306 | return self._inspect_all() |
| 307 | top = key.split(".")[0] |
| 308 | if top in self._DENIED_ATTRS or top.startswith("__"): |
| 309 | return f"Error: '{top}' is not accessible" |
| 310 | obj, err = self._resolve_path(key) |
| 311 | if err: |
| 312 | # "scratchpad" alias for _runtime_vars |
| 313 | if key == "scratchpad": |
| 314 | rv = self._loop._runtime_vars |
| 315 | return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty" |
| 316 | # Fallback: check _runtime_vars for simple keys stored by modify |
| 317 | if "." not in key and key in self._loop._runtime_vars: |
| 318 | return self._format_value(self._loop._runtime_vars[key], key) |
| 319 | return f"Error: {err}" |
| 320 | # Guard against mock auto-generated attributes |
| 321 | if "." not in key and not _has_real_attr(self._loop, key): |
| 322 | if key in self._loop._runtime_vars: |
| 323 | return self._format_value(self._loop._runtime_vars[key], key) |
| 324 | return f"Error: '{key}' not found" |
| 325 | return self._format_value(obj, key) |
| 326 | |
| 327 | def _inspect_all(self) -> str: |
| 328 | loop = self._loop |
| 329 | parts: list[str] = [] |
| 330 | # RESTRICTED keys |
| 331 | for k in self.RESTRICTED: |
| 332 | parts.append(self._format_value(getattr(loop, k, None), k)) |
| 333 | # Other useful top-level keys shown in description |
| 334 | for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"): |
| 335 | if _has_real_attr(loop, k): |
| 336 | parts.append(self._format_value(getattr(loop, k, None), k)) |
| 337 | # Token usage |
| 338 | usage = loop._last_usage |
| 339 | if usage: |
| 340 | parts.append(self._format_value(usage, "_last_usage")) |
| 341 | rv = loop._runtime_vars |
| 342 | if rv: |
| 343 | parts.append(self._format_value(rv, "scratchpad")) |
| 344 | return "\n".join(parts) |
| 345 | |
| 346 | # -- modify -- |
| 347 | |
| 348 | def _modify(self, key: str | None, value: Any) -> str: |
| 349 | if err := self._validate_key(key): |
| 350 | return err |
| 351 | top = key.split(".")[0] |
| 352 | if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES: |
| 353 | self._audit("modify", f"BLOCKED {key}") |
| 354 | return f"Error: '{key}' is protected and cannot be modified" |
| 355 | if top in self.READ_ONLY: |
| 356 | self._audit("modify", f"READ_ONLY {key}") |
| 357 | return f"Error: '{key}' is read-only and cannot be modified" |
| 358 | if "." in key: |
| 359 | parent_path, leaf = key.rsplit(".", 1) |
| 360 | if leaf in self._DENIED_ATTRS or leaf.startswith("__"): |
| 361 | self._audit("modify", f"BLOCKED leaf '{leaf}'") |
| 362 | return f"Error: '{leaf}' is not accessible" |
| 363 | if leaf.lower() in self._SENSITIVE_NAMES: |
| 364 | self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'") |
| 365 | return f"Error: '{leaf}' is not accessible" |
| 366 | parent, err = self._resolve_path(parent_path) |
| 367 | if err: |
| 368 | return f"Error: {err}" |
| 369 | if isinstance(parent, dict): |
| 370 | parent[leaf] = value |
| 371 | else: |
| 372 | setattr(parent, leaf, value) |
| 373 | self._audit("modify", f"{key} = {value!r}") |
| 374 | return f"Set {key} = {value!r}" |
| 375 | if key in self.RESTRICTED: |
| 376 | return self._modify_restricted(key, value) |
| 377 | return self._modify_free(key, value) |
| 378 | |
| 379 | def _modify_restricted(self, key: str, value: Any) -> str: |
| 380 | spec = self.RESTRICTED[key] |
| 381 | expected = spec["type"] |
| 382 | if expected is int and isinstance(value, bool): |
| 383 | return f"Error: '{key}' must be {expected.__name__}, got bool" |
| 384 | if not isinstance(value, expected): |
| 385 | try: |
| 386 | value = expected(value) |
| 387 | except (ValueError, TypeError): |
| 388 | return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}" |
| 389 | old = getattr(self._loop, key) |
| 390 | if "min" in spec and value < spec["min"]: |
| 391 | return f"Error: '{key}' must be >= {spec['min']}" |
| 392 | if "max" in spec and value > spec["max"]: |
| 393 | return f"Error: '{key}' must be <= {spec['max']}" |
| 394 | if "min_len" in spec and len(str(value)) < spec["min_len"]: |
| 395 | return f"Error: '{key}' must be at least {spec['min_len']} characters" |
| 396 | setattr(self._loop, key, value) |
| 397 | self._audit("modify", f"{key}: {old!r} -> {value!r}") |
| 398 | return f"Set {key} = {value!r} (was {old!r})" |
| 399 | |
| 400 | def _modify_free(self, key: str, value: Any) -> str: |
| 401 | if _has_real_attr(self._loop, key): |
| 402 | old = getattr(self._loop, key) |
| 403 | if isinstance(old, (str, int, float, bool)): |
| 404 | old_t, new_t = type(old), type(value) |
| 405 | if old_t is float and new_t is int: |
| 406 | pass # int → float coercion allowed |
| 407 | elif old_t is not new_t: |
| 408 | self._audit( |
| 409 | "modify", |
| 410 | f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}", |
| 411 | ) |
| 412 | return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}" |
| 413 | setattr(self._loop, key, value) |
| 414 | self._audit("modify", f"{key}: {old!r} -> {value!r}") |
| 415 | return f"Set {key} = {value!r} (was {old!r})" |
| 416 | if callable(value): |
| 417 | self._audit("modify", f"REJECTED callable {key}") |
| 418 | return "Error: cannot store callable values" |
| 419 | err = self._validate_json_safe(value) |
| 420 | if err: |
| 421 | self._audit("modify", f"REJECTED {key}: {err}") |
| 422 | return f"Error: {err}" |
| 423 | if key not in self._loop._runtime_vars and len(self._loop._runtime_vars) >= self._MAX_RUNTIME_KEYS: |
| 424 | self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached") |
| 425 | return f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first." |
| 426 | old = self._loop._runtime_vars.get(key) |
| 427 | self._loop._runtime_vars[key] = value |
| 428 | self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}") |
| 429 | return f"Set scratchpad.{key} = {value!r}" |
| 430 | |
| 431 | @classmethod |
| 432 | def _validate_json_safe(cls, value: Any, depth: int = 0) -> str | None: |
| 433 | if depth > 10: |
| 434 | return "value nesting too deep (max 10 levels)" |
| 435 | if isinstance(value, (str, int, float, bool, type(None))): |
| 436 | return None |
| 437 | if isinstance(value, list): |
| 438 | for i, item in enumerate(value): |
| 439 | if err := cls._validate_json_safe(item, depth + 1): |
| 440 | return f"list[{i}] contains {err}" |
| 441 | return None |
| 442 | if isinstance(value, dict): |
| 443 | for k, v in value.items(): |
| 444 | if not isinstance(k, str): |
| 445 | return f"dict key must be str, got {type(k).__name__}" |
| 446 | if err := cls._validate_json_safe(v, depth + 1): |
| 447 | return f"dict key '{k}' contains {err}" |
| 448 | return None |
| 449 | return f"unsupported type {type(value).__name__}" |
| 450 |