返回 JoyAI-Echo
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
450 lines PYTHON