diff --git a/agent/builtin_memory_provider.py b/agent/builtin_memory_provider.py index 0d9cf6c06..77df9a303 100644 --- a/agent/builtin_memory_provider.py +++ b/agent/builtin_memory_provider.py @@ -16,6 +16,7 @@ import logging from typing import Any, Dict, List from agent.memory_provider import MemoryProvider +from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -92,7 +93,7 @@ class BuiltinMemoryProvider(MemoryProvider): def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str: """Not used — the memory tool is intercepted in run_agent.py.""" - return json.dumps({"error": "Built-in memory tool is handled by the agent loop"}) + return tool_error("Built-in memory tool is handled by the agent loop") def shutdown(self) -> None: """No cleanup needed — files are saved on every write.""" diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 0e4113eff..4630c481f 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -34,6 +34,7 @@ import re from typing import Any, Dict, List, Optional from agent.memory_provider import MemoryProvider +from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -249,7 +250,7 @@ class MemoryManager: """ provider = self._tool_to_provider.get(tool_name) if provider is None: - return json.dumps({"error": f"No memory provider handles tool '{tool_name}'"}) + return tool_error(f"No memory provider handles tool '{tool_name}'") try: return provider.handle_tool_call(tool_name, args, **kwargs) except Exception as e: @@ -257,7 +258,7 @@ class MemoryManager: "Memory provider '%s' handle_tool_call(%s) failed: %s", provider.name, tool_name, e, ) - return json.dumps({"error": f"Memory tool '{tool_name}' failed: {e}"}) + return tool_error(f"Memory tool '{tool_name}' failed: {e}") # -- Lifecycle hooks ----------------------------------------------------- diff --git a/hermes_cli/config.py b/hermes_cli/config.py index d90fc2155..405b83ac9 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1881,6 +1881,24 @@ def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]: +def read_raw_config() -> Dict[str, Any]: + """Read ~/.hermes/config.yaml as-is, without merging defaults or migrating. + + Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't + be parsed. Use this for lightweight config reads where you just need a + single value and don't want the overhead of ``load_config()``'s deep-merge + + migration pipeline. + """ + try: + config_path = get_config_path() + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + return yaml.safe_load(f) or {} + except Exception: + pass + return {} + + def load_config() -> Dict[str, Any]: """Load configuration from ~/.hermes/config.yaml.""" import copy diff --git a/plugins/memory/byterover/__init__.py b/plugins/memory/byterover/__init__.py index d73440c78..1870e9ab8 100644 --- a/plugins/memory/byterover/__init__.py +++ b/plugins/memory/byterover/__init__.py @@ -27,6 +27,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional from agent.memory_provider import MemoryProvider +from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -320,7 +321,7 @@ class ByteRoverMemoryProvider(MemoryProvider): return self._tool_curate(args) elif tool_name == "brv_status": return self._tool_status() - return json.dumps({"error": f"Unknown tool: {tool_name}"}) + return tool_error(f"Unknown tool: {tool_name}") def shutdown(self) -> None: if self._sync_thread and self._sync_thread.is_alive(): @@ -331,7 +332,7 @@ class ByteRoverMemoryProvider(MemoryProvider): def _tool_query(self, args: dict) -> str: query = args.get("query", "") if not query: - return json.dumps({"error": "query is required"}) + return tool_error("query is required") result = _run_brv( ["query", "--", query.strip()[:5000]], @@ -339,7 +340,7 @@ class ByteRoverMemoryProvider(MemoryProvider): ) if not result["success"]: - return json.dumps({"error": result.get("error", "Query failed")}) + return tool_error(result.get("error", "Query failed")) output = result.get("output", "").strip() if not output or len(output) < _MIN_OUTPUT_LEN: @@ -354,7 +355,7 @@ class ByteRoverMemoryProvider(MemoryProvider): def _tool_curate(self, args: dict) -> str: content = args.get("content", "") if not content: - return json.dumps({"error": "content is required"}) + return tool_error("content is required") result = _run_brv( ["curate", "--", content], @@ -362,14 +363,14 @@ class ByteRoverMemoryProvider(MemoryProvider): ) if not result["success"]: - return json.dumps({"error": result.get("error", "Curate failed")}) + return tool_error(result.get("error", "Curate failed")) return json.dumps({"result": "Memory curated successfully."}) def _tool_status(self) -> str: result = _run_brv(["status"], timeout=15, cwd=self._cwd) if not result["success"]: - return json.dumps({"error": result.get("error", "Status check failed")}) + return tool_error(result.get("error", "Status check failed")) return json.dumps({"status": result.get("output", "")}) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index e10a14a84..51feb3cb6 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -26,6 +26,7 @@ import threading from typing import Any, Dict, List from agent.memory_provider import MemoryProvider +from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -433,12 +434,12 @@ class HindsightMemoryProvider(MemoryProvider): client = self._get_client() except Exception as e: logger.warning("Hindsight client init failed: %s", e) - return json.dumps({"error": f"Hindsight client unavailable: {e}"}) + return tool_error(f"Hindsight client unavailable: {e}") if tool_name == "hindsight_retain": content = args.get("content", "") if not content: - return json.dumps({"error": "Missing required parameter: content"}) + return tool_error("Missing required parameter: content") context = args.get("context") try: _run_sync(client.aretain( @@ -447,12 +448,12 @@ class HindsightMemoryProvider(MemoryProvider): return json.dumps({"result": "Memory stored successfully."}) except Exception as e: logger.warning("hindsight_retain failed: %s", e) - return json.dumps({"error": f"Failed to store memory: {e}"}) + return tool_error(f"Failed to store memory: {e}") elif tool_name == "hindsight_recall": query = args.get("query", "") if not query: - return json.dumps({"error": "Missing required parameter: query"}) + return tool_error("Missing required parameter: query") try: resp = _run_sync(client.arecall( bank_id=self._bank_id, query=query, budget=self._budget @@ -463,12 +464,12 @@ class HindsightMemoryProvider(MemoryProvider): return json.dumps({"result": "\n".join(lines)}) except Exception as e: logger.warning("hindsight_recall failed: %s", e) - return json.dumps({"error": f"Failed to search memory: {e}"}) + return tool_error(f"Failed to search memory: {e}") elif tool_name == "hindsight_reflect": query = args.get("query", "") if not query: - return json.dumps({"error": "Missing required parameter: query"}) + return tool_error("Missing required parameter: query") try: resp = _run_sync(client.areflect( bank_id=self._bank_id, query=query, budget=self._budget @@ -476,9 +477,9 @@ class HindsightMemoryProvider(MemoryProvider): return json.dumps({"result": resp.text or "No relevant memories found."}) except Exception as e: logger.warning("hindsight_reflect failed: %s", e) - return json.dumps({"error": f"Failed to reflect: {e}"}) + return tool_error(f"Failed to reflect: {e}") - return json.dumps({"error": f"Unknown tool: {tool_name}"}) + return tool_error(f"Unknown tool: {tool_name}") def shutdown(self) -> None: global _loop, _loop_thread diff --git a/plugins/memory/holographic/__init__.py b/plugins/memory/holographic/__init__.py index b1423c105..cd4ef07b4 100644 --- a/plugins/memory/holographic/__init__.py +++ b/plugins/memory/holographic/__init__.py @@ -23,6 +23,7 @@ import re from typing import Any, Dict, List from agent.memory_provider import MemoryProvider +from tools.registry import tool_error from .store import MemoryStore from .retrieval import FactRetriever @@ -230,7 +231,7 @@ class HolographicMemoryProvider(MemoryProvider): return self._handle_fact_store(args) elif tool_name == "fact_feedback": return self._handle_fact_feedback(args) - return json.dumps({"error": f"Unknown tool: {tool_name}"}) + return tool_error(f"Unknown tool: {tool_name}") def on_session_end(self, messages: List[Dict[str, Any]]) -> None: if not self._config.get("auto_extract", False): @@ -296,7 +297,7 @@ class HolographicMemoryProvider(MemoryProvider): elif action == "reason": entities = args.get("entities", []) if not entities: - return json.dumps({"error": "reason requires 'entities' list"}) + return tool_error("reason requires 'entities' list") results = retriever.reason( entities, category=args.get("category"), @@ -334,12 +335,12 @@ class HolographicMemoryProvider(MemoryProvider): return json.dumps({"facts": facts, "count": len(facts)}) else: - return json.dumps({"error": f"Unknown action: {action}"}) + return tool_error(f"Unknown action: {action}") except KeyError as exc: - return json.dumps({"error": f"Missing required argument: {exc}"}) + return tool_error(f"Missing required argument: {exc}") except Exception as exc: - return json.dumps({"error": str(exc)}) + return tool_error(str(exc)) def _handle_fact_feedback(self, args: dict) -> str: try: @@ -348,9 +349,9 @@ class HolographicMemoryProvider(MemoryProvider): result = self._store.record_feedback(fact_id, helpful=helpful) return json.dumps(result) except KeyError as exc: - return json.dumps({"error": f"Missing required argument: {exc}"}) + return tool_error(f"Missing required argument: {exc}") except Exception as exc: - return json.dumps({"error": str(exc)}) + return tool_error(str(exc)) # -- Auto-extraction (on_session_end) ------------------------------------ diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index 782af5791..e8078ae58 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -21,6 +21,7 @@ import threading from typing import Any, Dict, List, Optional from agent.memory_provider import MemoryProvider +from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -638,15 +639,15 @@ class HonchoMemoryProvider(MemoryProvider): def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: """Handle a Honcho tool call, with lazy session init for tools-only mode.""" if self._cron_skipped: - return json.dumps({"error": "Honcho is not active (cron context)."}) + return tool_error("Honcho is not active (cron context).") # Port #1957: ensure session is initialized for tools-only mode if not self._session_initialized: if not self._ensure_session(): - return json.dumps({"error": "Honcho session could not be initialized."}) + return tool_error("Honcho session could not be initialized.") if not self._manager or not self._session_key: - return json.dumps({"error": "Honcho is not active for this session."}) + return tool_error("Honcho is not active for this session.") try: if tool_name == "honcho_profile": @@ -658,7 +659,7 @@ class HonchoMemoryProvider(MemoryProvider): elif tool_name == "honcho_search": query = args.get("query", "") if not query: - return json.dumps({"error": "Missing required parameter: query"}) + return tool_error("Missing required parameter: query") max_tokens = min(int(args.get("max_tokens", 800)), 2000) result = self._manager.search_context( self._session_key, query, max_tokens=max_tokens @@ -670,7 +671,7 @@ class HonchoMemoryProvider(MemoryProvider): elif tool_name == "honcho_context": query = args.get("query", "") if not query: - return json.dumps({"error": "Missing required parameter: query"}) + return tool_error("Missing required parameter: query") peer = args.get("peer", "user") result = self._manager.dialectic_query( self._session_key, query, peer=peer @@ -680,17 +681,17 @@ class HonchoMemoryProvider(MemoryProvider): elif tool_name == "honcho_conclude": conclusion = args.get("conclusion", "") if not conclusion: - return json.dumps({"error": "Missing required parameter: conclusion"}) + return tool_error("Missing required parameter: conclusion") ok = self._manager.create_conclusion(self._session_key, conclusion) if ok: return json.dumps({"result": f"Conclusion saved: {conclusion}"}) - return json.dumps({"error": "Failed to save conclusion."}) + return tool_error("Failed to save conclusion.") - return json.dumps({"error": f"Unknown tool: {tool_name}"}) + return tool_error(f"Unknown tool: {tool_name}") except Exception as e: logger.error("Honcho tool %s failed: %s", tool_name, e) - return json.dumps({"error": f"Honcho {tool_name} failed: {e}"}) + return tool_error(f"Honcho {tool_name} failed: {e}") def shutdown(self) -> None: for t in (self._prefetch_thread, self._sync_thread): diff --git a/plugins/memory/mem0/__init__.py b/plugins/memory/mem0/__init__.py index dc56becd3..32d1f6ff7 100644 --- a/plugins/memory/mem0/__init__.py +++ b/plugins/memory/mem0/__init__.py @@ -23,6 +23,7 @@ import time from typing import Any, Dict, List from agent.memory_provider import MemoryProvider +from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -305,7 +306,7 @@ class Mem0MemoryProvider(MemoryProvider): try: client = self._get_client() except Exception as e: - return json.dumps({"error": str(e)}) + return tool_error(str(e)) if tool_name == "mem0_profile": try: @@ -317,12 +318,12 @@ class Mem0MemoryProvider(MemoryProvider): return json.dumps({"result": "\n".join(lines), "count": len(lines)}) except Exception as e: self._record_failure() - return json.dumps({"error": f"Failed to fetch profile: {e}"}) + return tool_error(f"Failed to fetch profile: {e}") elif tool_name == "mem0_search": query = args.get("query", "") if not query: - return json.dumps({"error": "Missing required parameter: query"}) + return tool_error("Missing required parameter: query") rerank = args.get("rerank", False) top_k = min(int(args.get("top_k", 10)), 50) try: @@ -339,12 +340,12 @@ class Mem0MemoryProvider(MemoryProvider): return json.dumps({"results": items, "count": len(items)}) except Exception as e: self._record_failure() - return json.dumps({"error": f"Search failed: {e}"}) + return tool_error(f"Search failed: {e}") elif tool_name == "mem0_conclude": conclusion = args.get("conclusion", "") if not conclusion: - return json.dumps({"error": "Missing required parameter: conclusion"}) + return tool_error("Missing required parameter: conclusion") try: client.add( [{"role": "user", "content": conclusion}], @@ -355,9 +356,9 @@ class Mem0MemoryProvider(MemoryProvider): return json.dumps({"result": "Fact stored."}) except Exception as e: self._record_failure() - return json.dumps({"error": f"Failed to store: {e}"}) + return tool_error(f"Failed to store: {e}") - return json.dumps({"error": f"Unknown tool: {tool_name}"}) + return tool_error(f"Unknown tool: {tool_name}") def shutdown(self) -> None: for t in (self._prefetch_thread, self._sync_thread): diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 596080c32..f46d71321 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -31,6 +31,7 @@ import threading from typing import Any, Dict, List, Optional from agent.memory_provider import MemoryProvider +from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -461,7 +462,7 @@ class OpenVikingMemoryProvider(MemoryProvider): def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: if not self._client: - return json.dumps({"error": "OpenViking server not connected"}) + return tool_error("OpenViking server not connected") try: if tool_name == "viking_search": @@ -474,9 +475,9 @@ class OpenVikingMemoryProvider(MemoryProvider): return self._tool_remember(args) elif tool_name == "viking_add_resource": return self._tool_add_resource(args) - return json.dumps({"error": f"Unknown tool: {tool_name}"}) + return tool_error(f"Unknown tool: {tool_name}") except Exception as e: - return json.dumps({"error": str(e)}) + return tool_error(str(e)) def shutdown(self) -> None: # Wait for background threads to finish @@ -493,7 +494,7 @@ class OpenVikingMemoryProvider(MemoryProvider): def _tool_search(self, args: dict) -> str: query = args.get("query", "") if not query: - return json.dumps({"error": "query is required"}) + return tool_error("query is required") payload: Dict[str, Any] = {"query": query} mode = args.get("mode", "auto") @@ -530,7 +531,7 @@ class OpenVikingMemoryProvider(MemoryProvider): def _tool_read(self, args: dict) -> str: uri = args.get("uri", "") if not uri: - return json.dumps({"error": "uri is required"}) + return tool_error("uri is required") level = args.get("level", "overview") # Map our level names to OpenViking GET endpoints @@ -582,7 +583,7 @@ class OpenVikingMemoryProvider(MemoryProvider): def _tool_remember(self, args: dict) -> str: content = args.get("content", "") if not content: - return json.dumps({"error": "content is required"}) + return tool_error("content is required") # Store as a session message that will be extracted during commit. # The category hint helps OpenViking's extraction classify correctly. @@ -606,7 +607,7 @@ class OpenVikingMemoryProvider(MemoryProvider): def _tool_add_resource(self, args: dict) -> str: url = args.get("url", "") if not url: - return json.dumps({"error": "url is required"}) + return tool_error("url is required") payload: Dict[str, Any] = {"path": url} if args.get("reason"): diff --git a/plugins/memory/retaindb/__init__.py b/plugins/memory/retaindb/__init__.py index 72ff9d77c..62121410d 100644 --- a/plugins/memory/retaindb/__init__.py +++ b/plugins/memory/retaindb/__init__.py @@ -34,6 +34,7 @@ from typing import Any, Dict, List from urllib.parse import quote from agent.memory_provider import MemoryProvider +from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -649,11 +650,11 @@ class RetainDBMemoryProvider(MemoryProvider): def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: if not self._client: - return json.dumps({"error": "RetainDB not initialized"}) + return tool_error("RetainDB not initialized") try: return json.dumps(self._dispatch(tool_name, args)) except Exception as exc: - return json.dumps({"error": str(exc)}) + return tool_error(str(exc)) def _dispatch(self, tool_name: str, args: dict) -> Any: c = self._client diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py index ee2c35e6f..08ac35759 100644 --- a/plugins/memory/supermemory/__init__.py +++ b/plugins/memory/supermemory/__init__.py @@ -18,6 +18,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional from agent.memory_provider import MemoryProvider +from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -587,7 +588,7 @@ class SupermemoryMemoryProvider(MemoryProvider): def _tool_store(self, args: dict) -> str: content = str(args.get("content") or "").strip() if not content: - return json.dumps({"error": "content is required"}) + return tool_error("content is required") metadata = args.get("metadata") or {} if not isinstance(metadata, dict): metadata = {} @@ -598,12 +599,12 @@ class SupermemoryMemoryProvider(MemoryProvider): preview = content[:80] + ("..." if len(content) > 80 else "") return json.dumps({"saved": True, "id": result.get("id", ""), "preview": preview}) except Exception as exc: - return json.dumps({"error": f"Failed to store memory: {exc}"}) + return tool_error(f"Failed to store memory: {exc}") def _tool_search(self, args: dict) -> str: query = str(args.get("query") or "").strip() if not query: - return json.dumps({"error": "query is required"}) + return tool_error("query is required") try: limit = max(1, min(20, int(args.get("limit", 5) or 5))) except Exception: @@ -621,20 +622,20 @@ class SupermemoryMemoryProvider(MemoryProvider): formatted.append(entry) return json.dumps({"results": formatted, "count": len(formatted)}) except Exception as exc: - return json.dumps({"error": f"Search failed: {exc}"}) + return tool_error(f"Search failed: {exc}") def _tool_forget(self, args: dict) -> str: memory_id = str(args.get("id") or "").strip() query = str(args.get("query") or "").strip() if not memory_id and not query: - return json.dumps({"error": "Provide either id or query"}) + return tool_error("Provide either id or query") try: if memory_id: self._client.forget_memory(memory_id) return json.dumps({"forgotten": True, "id": memory_id}) return json.dumps(self._client.forget_by_query(query)) except Exception as exc: - return json.dumps({"error": f"Forget failed: {exc}"}) + return tool_error(f"Forget failed: {exc}") def _tool_profile(self, args: dict) -> str: query = str(args.get("query") or "").strip() or None @@ -651,11 +652,11 @@ class SupermemoryMemoryProvider(MemoryProvider): "dynamic_count": len(profile["dynamic"]), }) except Exception as exc: - return json.dumps({"error": f"Profile failed: {exc}"}) + return tool_error(f"Profile failed: {exc}") def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str: if not self._active or not self._client: - return json.dumps({"error": "Supermemory is not configured"}) + return tool_error("Supermemory is not configured") if tool_name == "supermemory_store": return self._tool_store(args) if tool_name == "supermemory_search": @@ -664,7 +665,7 @@ class SupermemoryMemoryProvider(MemoryProvider): return self._tool_forget(args) if tool_name == "supermemory_profile": return self._tool_profile(args) - return json.dumps({"error": f"Unknown tool: {tool_name}"}) + return tool_error(f"Unknown tool: {tool_name}") def register(ctx): diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index 13e85c18d..08f26f509 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -259,7 +259,7 @@ def camofox_navigate(url: str, task_id: Optional[str] = None) -> str: return json.dumps(result) except requests.HTTPError as e: - return json.dumps({"success": False, "error": f"Navigation failed: {e}"}) + return tool_error(f"Navigation failed: {e}", success=False) except requests.ConnectionError: return json.dumps({ "success": False, @@ -268,7 +268,7 @@ def camofox_navigate(url: str, task_id: Optional[str] = None) -> str: "or: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser", }) except Exception as e: - return json.dumps({"success": False, "error": str(e)}) + return tool_error(str(e), success=False) def camofox_snapshot(full: bool = False, task_id: Optional[str] = None, @@ -277,7 +277,7 @@ def camofox_snapshot(full: bool = False, task_id: Optional[str] = None, try: session = _get_session(task_id) if not session["tab_id"]: - return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + return tool_error("No browser session. Call browser_navigate first.", success=False) data = _get( f"/tabs/{session['tab_id']}/snapshot", @@ -306,7 +306,7 @@ def camofox_snapshot(full: bool = False, task_id: Optional[str] = None, "element_count": refs_count, }) except Exception as e: - return json.dumps({"success": False, "error": str(e)}) + return tool_error(str(e), success=False) def camofox_click(ref: str, task_id: Optional[str] = None) -> str: @@ -314,7 +314,7 @@ def camofox_click(ref: str, task_id: Optional[str] = None) -> str: try: session = _get_session(task_id) if not session["tab_id"]: - return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + return tool_error("No browser session. Call browser_navigate first.", success=False) # Strip @ prefix if present (our tool convention) clean_ref = ref.lstrip("@") @@ -329,7 +329,7 @@ def camofox_click(ref: str, task_id: Optional[str] = None) -> str: "url": data.get("url", ""), }) except Exception as e: - return json.dumps({"success": False, "error": str(e)}) + return tool_error(str(e), success=False) def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str: @@ -337,7 +337,7 @@ def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str: try: session = _get_session(task_id) if not session["tab_id"]: - return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + return tool_error("No browser session. Call browser_navigate first.", success=False) clean_ref = ref.lstrip("@") @@ -351,7 +351,7 @@ def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str: "element": clean_ref, }) except Exception as e: - return json.dumps({"success": False, "error": str(e)}) + return tool_error(str(e), success=False) def camofox_scroll(direction: str, task_id: Optional[str] = None) -> str: @@ -359,7 +359,7 @@ def camofox_scroll(direction: str, task_id: Optional[str] = None) -> str: try: session = _get_session(task_id) if not session["tab_id"]: - return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + return tool_error("No browser session. Call browser_navigate first.", success=False) _post( f"/tabs/{session['tab_id']}/scroll", @@ -367,7 +367,7 @@ def camofox_scroll(direction: str, task_id: Optional[str] = None) -> str: ) return json.dumps({"success": True, "scrolled": direction}) except Exception as e: - return json.dumps({"success": False, "error": str(e)}) + return tool_error(str(e), success=False) def camofox_back(task_id: Optional[str] = None) -> str: @@ -375,7 +375,7 @@ def camofox_back(task_id: Optional[str] = None) -> str: try: session = _get_session(task_id) if not session["tab_id"]: - return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + return tool_error("No browser session. Call browser_navigate first.", success=False) data = _post( f"/tabs/{session['tab_id']}/back", @@ -383,7 +383,7 @@ def camofox_back(task_id: Optional[str] = None) -> str: ) return json.dumps({"success": True, "url": data.get("url", "")}) except Exception as e: - return json.dumps({"success": False, "error": str(e)}) + return tool_error(str(e), success=False) def camofox_press(key: str, task_id: Optional[str] = None) -> str: @@ -391,7 +391,7 @@ def camofox_press(key: str, task_id: Optional[str] = None) -> str: try: session = _get_session(task_id) if not session["tab_id"]: - return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + return tool_error("No browser session. Call browser_navigate first.", success=False) _post( f"/tabs/{session['tab_id']}/press", @@ -399,7 +399,7 @@ def camofox_press(key: str, task_id: Optional[str] = None) -> str: ) return json.dumps({"success": True, "pressed": key}) except Exception as e: - return json.dumps({"success": False, "error": str(e)}) + return tool_error(str(e), success=False) def camofox_close(task_id: Optional[str] = None) -> str: @@ -426,7 +426,7 @@ def camofox_get_images(task_id: Optional[str] = None) -> str: try: session = _get_session(task_id) if not session["tab_id"]: - return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + return tool_error("No browser session. Call browser_navigate first.", success=False) import re @@ -461,7 +461,7 @@ def camofox_get_images(task_id: Optional[str] = None) -> str: "count": len(images), }) except Exception as e: - return json.dumps({"success": False, "error": str(e)}) + return tool_error(str(e), success=False) def camofox_vision(question: str, annotate: bool = False, @@ -470,7 +470,7 @@ def camofox_vision(question: str, annotate: bool = False, try: session = _get_session(task_id) if not session["tab_id"]: - return json.dumps({"success": False, "error": "No browser session. Call browser_navigate first."}) + return tool_error("No browser session. Call browser_navigate first.", success=False) # Get screenshot as binary PNG resp = _get_raw( @@ -551,7 +551,7 @@ def camofox_vision(question: str, annotate: bool = False, "screenshot_path": screenshot_path, }) except Exception as e: - return json.dumps({"success": False, "error": str(e)}) + return tool_error(str(e), success=False) def camofox_console(clear: bool = False, task_id: Optional[str] = None) -> str: diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 317c1fb1b..10004b089 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -1618,7 +1618,7 @@ def _camofox_eval(expression: str, task_id: Optional[str] = None) -> str: "error": "JavaScript evaluation is not supported by this Camofox server. " "Use browser_snapshot or browser_vision to inspect page state.", }) - return json.dumps({"success": False, "error": error_msg}) + return tool_error(error_msg, success=False) def _maybe_start_recording(task_id: str): @@ -2102,7 +2102,7 @@ if __name__ == "__main__": # --------------------------------------------------------------------------- # Registry # --------------------------------------------------------------------------- -from tools.registry import registry +from tools.registry import registry, tool_error _BROWSER_SCHEMA_MAP = {s["name"]: s for s in BROWSER_TOOL_SCHEMAS} diff --git a/tools/clarify_tool.py b/tools/clarify_tool.py index ece33eb5b..c44787554 100644 --- a/tools/clarify_tool.py +++ b/tools/clarify_tool.py @@ -40,14 +40,14 @@ def clarify_tool( JSON string with the user's response. """ if not question or not question.strip(): - return json.dumps({"error": "Question text is required."}, ensure_ascii=False) + return tool_error("Question text is required.") question = question.strip() # Validate and trim choices if choices is not None: if not isinstance(choices, list): - return json.dumps({"error": "choices must be a list of strings."}, ensure_ascii=False) + return tool_error("choices must be a list of strings.") choices = [str(c).strip() for c in choices if str(c).strip()] if len(choices) > MAX_CHOICES: choices = choices[:MAX_CHOICES] @@ -126,7 +126,7 @@ CLARIFY_SCHEMA = { # --- Registry --- -from tools.registry import registry +from tools.registry import registry, tool_error registry.register( name="clarify", diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index a7a806065..77be55697 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -344,7 +344,7 @@ def _rpc_server_loop( try: request = json.loads(line.decode()) except (json.JSONDecodeError, UnicodeDecodeError) as exc: - resp = json.dumps({"error": f"Invalid RPC request: {exc}"}) + resp = tool_error(f"Invalid RPC request: {exc}") conn.sendall((resp + "\n").encode()) continue @@ -396,7 +396,7 @@ def _rpc_server_loop( devnull.close() except Exception as exc: logger.error("Tool call failed in sandbox: %s", exc, exc_info=True) - result = json.dumps({"error": str(exc)}) + result = tool_error(str(exc)) tool_call_counter[0] += 1 call_duration = time.monotonic() - call_start @@ -648,7 +648,7 @@ def _rpc_poll_loop( except Exception as exc: logger.error("Tool call failed in remote sandbox: %s", exc, exc_info=True) - tool_result = json.dumps({"error": str(exc)}) + tool_result = tool_error(str(exc)) tool_call_counter[0] += 1 call_duration = time.monotonic() - call_start @@ -890,7 +890,7 @@ def execute_code( }) if not code or not code.strip(): - return json.dumps({"error": "No code provided."}) + return tool_error("No code provided.") # Dispatch: remote backends use file-based RPC, local uses UDS from tools.terminal_tool import _get_env_config @@ -1331,7 +1331,7 @@ EXECUTE_CODE_SCHEMA = build_execute_code_schema() # --- Registry --- -from tools.registry import registry +from tools.registry import registry, tool_error registry.register( name="execute_code", diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 0578dca5c..82d43c588 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -231,20 +231,20 @@ def cronjob( if normalized == "create": if not schedule: - return json.dumps({"success": False, "error": "schedule is required for create"}, indent=2) + return tool_error("schedule is required for create", success=False) canonical_skills = _canonical_skills(skill, skills) if not prompt and not canonical_skills: - return json.dumps({"success": False, "error": "create requires either prompt or at least one skill"}, indent=2) + return tool_error("create requires either prompt or at least one skill", success=False) if prompt: scan_error = _scan_cron_prompt(prompt) if scan_error: - return json.dumps({"success": False, "error": scan_error}, indent=2) + return tool_error(scan_error, success=False) # Validate script path before storing if script: script_error = _validate_cron_script_path(script) if script_error: - return json.dumps({"success": False, "error": script_error}, indent=2) + return tool_error(script_error, success=False) job = create_job( prompt=prompt or "", @@ -281,7 +281,7 @@ def cronjob( return json.dumps({"success": True, "count": len(jobs), "jobs": jobs}, indent=2) if not job_id: - return json.dumps({"success": False, "error": f"job_id is required for action '{normalized}'"}, indent=2) + return tool_error(f"job_id is required for action '{normalized}'", success=False) job = get_job(job_id) if not job: @@ -293,7 +293,7 @@ def cronjob( if normalized == "remove": removed = remove_job(job_id) if not removed: - return json.dumps({"success": False, "error": f"Failed to remove job '{job_id}'"}, indent=2) + return tool_error(f"Failed to remove job '{job_id}'", success=False) return json.dumps( { "success": True, @@ -324,7 +324,7 @@ def cronjob( if prompt is not None: scan_error = _scan_cron_prompt(prompt) if scan_error: - return json.dumps({"success": False, "error": scan_error}, indent=2) + return tool_error(scan_error, success=False) updates["prompt"] = prompt if name is not None: updates["name"] = name @@ -345,7 +345,7 @@ def cronjob( if script: script_error = _validate_cron_script_path(script) if script_error: - return json.dumps({"success": False, "error": script_error}, indent=2) + return tool_error(script_error, success=False) updates["script"] = _normalize_optional_job_value(script) if script else None if repeat is not None: # Normalize: treat 0 or negative as None (infinite) @@ -361,14 +361,14 @@ def cronjob( updates["state"] = "scheduled" updates["enabled"] = True if not updates: - return json.dumps({"success": False, "error": "No updates provided."}, indent=2) + return tool_error("No updates provided.", success=False) updated = update_job(job_id, updates) return json.dumps({"success": True, "job": _format_job(updated)}, indent=2) - return json.dumps({"success": False, "error": f"Unknown cron action '{action}'"}, indent=2) + return tool_error(f"Unknown cron action '{action}'", success=False) except Exception as e: - return json.dumps({"success": False, "error": str(e)}, indent=2) + return tool_error(str(e), success=False) # --------------------------------------------------------------------------- @@ -502,7 +502,7 @@ def check_cronjob_requirements() -> bool: # --- Registry --- -from tools.registry import registry +from tools.registry import registry, tool_error registry.register( name="cronjob", diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index ad9b54c44..a148a31f0 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -527,7 +527,7 @@ def delegate_task( Returns JSON with results array, one entry per task. """ if parent_agent is None: - return json.dumps({"error": "delegate_task requires a parent agent context."}) + return tool_error("delegate_task requires a parent agent context.") # Depth limit depth = getattr(parent_agent, '_delegate_depth', 0) @@ -552,7 +552,7 @@ def delegate_task( try: creds = _resolve_delegation_credentials(cfg, parent_agent) except ValueError as exc: - return json.dumps({"error": str(exc)}) + return tool_error(str(exc)) # Normalize to task list if tasks and isinstance(tasks, list): @@ -560,15 +560,15 @@ def delegate_task( elif goal and isinstance(goal, str) and goal.strip(): task_list = [{"goal": goal, "context": context, "toolsets": toolsets}] else: - return json.dumps({"error": "Provide either 'goal' (single task) or 'tasks' (batch)."}) + return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).") if not task_list: - return json.dumps({"error": "No tasks provided."}) + return tool_error("No tasks provided.") # Validate each task has a goal for i, task in enumerate(task_list): if not task.get("goal", "").strip(): - return json.dumps({"error": f"Task {i} is missing a 'goal'."}) + return tool_error(f"Task {i} is missing a 'goal'.") overall_start = time.monotonic() results = [] @@ -958,7 +958,7 @@ DELEGATE_TASK_SCHEMA = { # --- Registry --- -from tools.registry import registry +from tools.registry import registry, tool_error registry.register( name="delegate_task", diff --git a/tools/file_tools.py b/tools/file_tools.py index 626cae9ad..43e40315f 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -432,7 +432,7 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = return json.dumps(result_dict, ensure_ascii=False) except Exception as e: - return json.dumps({"error": str(e)}, ensure_ascii=False) + return tool_error(str(e)) def get_read_files_summary(task_id: str = "default") -> list: @@ -560,7 +560,7 @@ def write_file_tool(path: str, content: str, task_id: str = "default") -> str: """Write content to a file.""" sensitive_err = _check_sensitive_path(path) if sensitive_err: - return json.dumps({"error": sensitive_err}, ensure_ascii=False) + return tool_error(sensitive_err) try: stale_warning = _check_file_staleness(path, task_id) file_ops = _get_file_ops(task_id) @@ -577,7 +577,7 @@ def write_file_tool(path: str, content: str, task_id: str = "default") -> str: logger.debug("write_file expected denial: %s: %s", type(e).__name__, e) else: logger.error("write_file error: %s: %s", type(e).__name__, e, exc_info=True) - return json.dumps({"error": str(e)}, ensure_ascii=False) + return tool_error(str(e)) def patch_tool(mode: str = "replace", path: str = None, old_string: str = None, @@ -595,7 +595,7 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None, for _p in _paths_to_check: sensitive_err = _check_sensitive_path(_p) if sensitive_err: - return json.dumps({"error": sensitive_err}, ensure_ascii=False) + return tool_error(sensitive_err) try: # Check staleness for all files this patch will touch. stale_warnings = [] @@ -608,16 +608,16 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None, if mode == "replace": if not path: - return json.dumps({"error": "path required"}) + return tool_error("path required") if old_string is None or new_string is None: - return json.dumps({"error": "old_string and new_string required"}) + return tool_error("old_string and new_string required") result = file_ops.patch_replace(path, old_string, new_string, replace_all) elif mode == "patch": if not patch: - return json.dumps({"error": "patch content required"}) + return tool_error("patch content required") result = file_ops.patch_v4a(patch) else: - return json.dumps({"error": f"Unknown mode: {mode}"}) + return tool_error(f"Unknown mode: {mode}") result_dict = result.to_dict() if stale_warnings: @@ -634,7 +634,7 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None, result_json += "\n\n[Hint: old_string not found. Use read_file to verify the current content, or search_files to locate the text.]" return result_json except Exception as e: - return json.dumps({"error": str(e)}, ensure_ascii=False) + return tool_error(str(e)) def search_tool(pattern: str, target: str = "content", path: str = ".", @@ -702,7 +702,7 @@ def search_tool(pattern: str, target: str = "content", path: str = ".", result_json += f"\n\n[Hint: Results truncated. Use offset={next_offset} to see more, or narrow with a more specific pattern or file_glob.]" return result_json except Exception as e: - return json.dumps({"error": str(e)}, ensure_ascii=False) + return tool_error(str(e)) FILE_TOOLS = [ @@ -716,7 +716,7 @@ FILE_TOOLS = [ # --------------------------------------------------------------------------- # Schemas + Registry # --------------------------------------------------------------------------- -from tools.registry import registry +from tools.registry import registry, tool_error def _check_file_reqs(): diff --git a/tools/homeassistant_tool.py b/tools/homeassistant_tool.py index 62125a7f7..0ab99b4bf 100644 --- a/tools/homeassistant_tool.py +++ b/tools/homeassistant_tool.py @@ -221,22 +221,22 @@ def _handle_list_entities(args: dict, **kw) -> str: return json.dumps({"result": result}) except Exception as e: logger.error("ha_list_entities error: %s", e) - return json.dumps({"error": f"Failed to list entities: {e}"}) + return tool_error(f"Failed to list entities: {e}") def _handle_get_state(args: dict, **kw) -> str: """Handler for ha_get_state tool.""" entity_id = args.get("entity_id", "") if not entity_id: - return json.dumps({"error": "Missing required parameter: entity_id"}) + return tool_error("Missing required parameter: entity_id") if not _ENTITY_ID_RE.match(entity_id): - return json.dumps({"error": f"Invalid entity_id format: {entity_id}"}) + return tool_error(f"Invalid entity_id format: {entity_id}") try: result = _run_async(_async_get_state(entity_id)) return json.dumps({"result": result}) except Exception as e: logger.error("ha_get_state error: %s", e) - return json.dumps({"error": f"Failed to get state for {entity_id}: {e}"}) + return tool_error(f"Failed to get state for {entity_id}: {e}") def _handle_call_service(args: dict, **kw) -> str: @@ -244,7 +244,7 @@ def _handle_call_service(args: dict, **kw) -> str: domain = args.get("domain", "") service = args.get("service", "") if not domain or not service: - return json.dumps({"error": "Missing required parameters: domain and service"}) + return tool_error("Missing required parameters: domain and service") if domain in _BLOCKED_DOMAINS: return json.dumps({ @@ -254,7 +254,7 @@ def _handle_call_service(args: dict, **kw) -> str: entity_id = args.get("entity_id") if entity_id and not _ENTITY_ID_RE.match(entity_id): - return json.dumps({"error": f"Invalid entity_id format: {entity_id}"}) + return tool_error(f"Invalid entity_id format: {entity_id}") data = args.get("data") try: @@ -262,7 +262,7 @@ def _handle_call_service(args: dict, **kw) -> str: return json.dumps({"result": result}) except Exception as e: logger.error("ha_call_service error: %s", e) - return json.dumps({"error": f"Failed to call {domain}.{service}: {e}"}) + return tool_error(f"Failed to call {domain}.{service}: {e}") # --------------------------------------------------------------------------- @@ -311,7 +311,7 @@ def _handle_list_services(args: dict, **kw) -> str: return json.dumps({"result": result}) except Exception as e: logger.error("ha_list_services error: %s", e) - return json.dumps({"error": f"Failed to list services: {e}"}) + return tool_error(f"Failed to list services: {e}") # --------------------------------------------------------------------------- @@ -451,7 +451,7 @@ HA_CALL_SERVICE_SCHEMA = { # Registration # --------------------------------------------------------------------------- -from tools.registry import registry +from tools.registry import registry, tool_error registry.register( name="ha_list_entities", diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 77e090529..edf43dec7 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -652,7 +652,7 @@ if __name__ == "__main__": # --------------------------------------------------------------------------- # Registry # --------------------------------------------------------------------------- -from tools.registry import registry +from tools.registry import registry, tool_error IMAGE_GENERATE_SCHEMA = { "name": "image_generate", @@ -679,7 +679,7 @@ IMAGE_GENERATE_SCHEMA = { def _handle_image_generate(args, **kw): prompt = args.get("prompt", "") if not prompt: - return json.dumps({"error": "prompt is required for image generation"}) + return tool_error("prompt is required for image generation") return image_generate_tool( prompt=prompt, aspect_ratio=args.get("aspect_ratio", "landscape"), diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 5e4101a93..2ed8ba210 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -792,7 +792,7 @@ class MCPServerTask: After the initial ``await`` (list_tools), all mutations are synchronous — atomic from the event loop's perspective. """ - from tools.registry import registry + from tools.registry import registry, tool_error from toolsets import TOOLSETS async with self._refresh_lock: @@ -1326,7 +1326,7 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float): uri = args.get("uri") if not uri: - return json.dumps({"error": "Missing required parameter 'uri'"}) + return tool_error("Missing required parameter 'uri'") async def _call(): result = await server.session.read_resource(uri) @@ -1415,7 +1415,7 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float): name = args.get("name") if not name: - return json.dumps({"error": "Missing required parameter 'name'"}) + return tool_error("Missing required parameter 'name'") arguments = args.get("arguments", {}) async def _call(): @@ -1724,7 +1724,7 @@ def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> Li Returns: List of registered prefixed tool names. """ - from tools.registry import registry + from tools.registry import registry, tool_error from toolsets import create_custom_toolset, TOOLSETS registered_names: List[str] = [] diff --git a/tools/memory_tool.py b/tools/memory_tool.py index f7b6bed2d..1feee269a 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -449,30 +449,30 @@ def memory_tool( Returns JSON string with results. """ if store is None: - return json.dumps({"success": False, "error": "Memory is not available. It may be disabled in config or this environment."}, ensure_ascii=False) + return tool_error("Memory is not available. It may be disabled in config or this environment.", success=False) if target not in ("memory", "user"): - return json.dumps({"success": False, "error": f"Invalid target '{target}'. Use 'memory' or 'user'."}, ensure_ascii=False) + return tool_error(f"Invalid target '{target}'. Use 'memory' or 'user'.", success=False) if action == "add": if not content: - return json.dumps({"success": False, "error": "Content is required for 'add' action."}, ensure_ascii=False) + return tool_error("Content is required for 'add' action.", success=False) result = store.add(target, content) elif action == "replace": if not old_text: - return json.dumps({"success": False, "error": "old_text is required for 'replace' action."}, ensure_ascii=False) + return tool_error("old_text is required for 'replace' action.", success=False) if not content: - return json.dumps({"success": False, "error": "content is required for 'replace' action."}, ensure_ascii=False) + return tool_error("content is required for 'replace' action.", success=False) result = store.replace(target, old_text, content) elif action == "remove": if not old_text: - return json.dumps({"success": False, "error": "old_text is required for 'remove' action."}, ensure_ascii=False) + return tool_error("old_text is required for 'remove' action.", success=False) result = store.remove(target, old_text) else: - return json.dumps({"success": False, "error": f"Unknown action '{action}'. Use: add, replace, remove"}, ensure_ascii=False) + return tool_error(f"Unknown action '{action}'. Use: add, replace, remove", success=False) return json.dumps(result, ensure_ascii=False) @@ -539,7 +539,7 @@ MEMORY_SCHEMA = { # --- Registry --- -from tools.registry import registry +from tools.registry import registry, tool_error registry.register( name="memory", diff --git a/tools/process_registry.py b/tools/process_registry.py index 86868f72d..948f073ab 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -821,7 +821,7 @@ process_registry = ProcessRegistry() # --------------------------------------------------------------------------- # Registry -- the "process" tool schema + handler # --------------------------------------------------------------------------- -from tools.registry import registry +from tools.registry import registry, tool_error PROCESS_SCHEMA = { "name": "process", @@ -879,7 +879,7 @@ def _handle_process(args, **kw): return _json.dumps({"processes": process_registry.list_sessions(task_id=task_id)}, ensure_ascii=False) elif action in ("poll", "log", "wait", "kill", "write", "submit"): if not session_id: - return _json.dumps({"error": f"session_id is required for {action}"}, ensure_ascii=False) + return tool_error(f"session_id is required for {action}") if action == "poll": return _json.dumps(process_registry.poll(session_id), ensure_ascii=False) elif action == "log": @@ -893,7 +893,7 @@ def _handle_process(args, **kw): return _json.dumps(process_registry.write_stdin(session_id, str(args.get("data", ""))), ensure_ascii=False) elif action == "submit": return _json.dumps(process_registry.submit_stdin(session_id, str(args.get("data", ""))), ensure_ascii=False) - return _json.dumps({"error": f"Unknown process action: {action}. Use: list, poll, log, wait, kill, write, submit"}, ensure_ascii=False) + return tool_error(f"Unknown process action: {action}. Use: list, poll, log, wait, kill, write, submit") registry.register( diff --git a/tools/registry.py b/tools/registry.py index 432e1f074..079052a3f 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -273,3 +273,48 @@ class ToolRegistry: # Module-level singleton registry = ToolRegistry() + + +# --------------------------------------------------------------------------- +# Helpers for tool response serialization +# --------------------------------------------------------------------------- +# Every tool handler must return a JSON string. These helpers eliminate the +# boilerplate ``json.dumps({"error": msg}, ensure_ascii=False)`` that appears +# hundreds of times across tool files. +# +# Usage: +# from tools.registry import registry, tool_error, tool_result +# +# return tool_error("something went wrong") +# return tool_error("not found", code=404) +# return tool_result(success=True, data=payload) +# return tool_result(items) # pass a dict directly + + +def tool_error(message, **extra) -> str: + """Return a JSON error string for tool handlers. + + >>> tool_error("file not found") + '{"error": "file not found"}' + >>> tool_error("bad input", success=False) + '{"error": "bad input", "success": false}' + """ + result = {"error": str(message)} + if extra: + result.update(extra) + return json.dumps(result, ensure_ascii=False) + + +def tool_result(data=None, **kwargs) -> str: + """Return a JSON result string for tool handlers. + + Accepts a dict positional arg *or* keyword arguments (not both): + + >>> tool_result(success=True, count=42) + '{"success": true, "count": 42}' + >>> tool_result({"key": "value"}) + '{"key": "value"}' + """ + if data is not None: + return json.dumps(data, ensure_ascii=False) + return json.dumps(kwargs, ensure_ascii=False) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 4e500e694..164b8a2f4 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -101,7 +101,7 @@ def _handle_send(args): target = args.get("target", "") message = args.get("message", "") if not target or not message: - return json.dumps({"error": "Both 'target' and 'message' are required when action='send'"}) + return tool_error("Both 'target' and 'message' are required when action='send'") parts = target.split(":", 1) platform_name = parts[0].strip().lower() @@ -134,7 +134,7 @@ def _handle_send(args): from tools.interrupt import is_interrupted if is_interrupted(): - return json.dumps({"error": "Interrupted"}) + return tool_error("Interrupted") try: from gateway.config import load_gateway_config, Platform @@ -160,11 +160,11 @@ def _handle_send(args): platform = platform_map.get(platform_name) if not platform: avail = ", ".join(platform_map.keys()) - return json.dumps({"error": f"Unknown platform: {platform_name}. Available: {avail}"}) + return tool_error(f"Unknown platform: {platform_name}. Available: {avail}") pconfig = config.platforms.get(platform) if not pconfig or not pconfig.enabled: - return json.dumps({"error": f"Platform '{platform_name}' is not configured. Set up credentials in ~/.hermes/config.yaml or environment variables."}) + return tool_error(f"Platform '{platform_name}' is not configured. Set up credentials in ~/.hermes/config.yaml or environment variables.") from gateway.platforms.base import BasePlatformAdapter @@ -940,7 +940,7 @@ def _check_send_message(): # --- Registry --- -from tools.registry import registry +from tools.registry import registry, tool_error registry.register( name="send_message", diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index 3ff36f940..3e9c68af4 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -241,7 +241,7 @@ def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str }, ensure_ascii=False) except Exception as e: logging.error("Error listing recent sessions: %s", e, exc_info=True) - return json.dumps({"success": False, "error": f"Failed to list recent sessions: {e}"}, ensure_ascii=False) + return tool_error(f"Failed to list recent sessions: {e}", success=False) def session_search( @@ -258,7 +258,7 @@ def session_search( The current session is excluded from results since the agent already has that context. """ if db is None: - return json.dumps({"success": False, "error": "Session database not available."}, ensure_ascii=False) + return tool_error("Session database not available.", success=False) limit = min(limit, 5) # Cap at 5 sessions to avoid excessive LLM calls @@ -427,7 +427,7 @@ def session_search( except Exception as e: logging.error("Session search failed: %s", e, exc_info=True) - return json.dumps({"success": False, "error": f"Search failed: {str(e)}"}, ensure_ascii=False) + return tool_error(f"Search failed: {str(e)}", success=False) def check_session_search_requirements() -> bool: @@ -487,7 +487,7 @@ SESSION_SEARCH_SCHEMA = { # --- Registry --- -from tools.registry import registry +from tools.registry import registry, tool_error registry.register( name="session_search", diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 1a7de513c..97a4bf5aa 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -584,19 +584,19 @@ def skill_manage( """ if action == "create": if not content: - return json.dumps({"success": False, "error": "content is required for 'create'. Provide the full SKILL.md text (frontmatter + body)."}, ensure_ascii=False) + return tool_error("content is required for 'create'. Provide the full SKILL.md text (frontmatter + body).", success=False) result = _create_skill(name, content, category) elif action == "edit": if not content: - return json.dumps({"success": False, "error": "content is required for 'edit'. Provide the full updated SKILL.md text."}, ensure_ascii=False) + return tool_error("content is required for 'edit'. Provide the full updated SKILL.md text.", success=False) result = _edit_skill(name, content) elif action == "patch": if not old_string: - return json.dumps({"success": False, "error": "old_string is required for 'patch'. Provide the text to find."}, ensure_ascii=False) + return tool_error("old_string is required for 'patch'. Provide the text to find.", success=False) if new_string is None: - return json.dumps({"success": False, "error": "new_string is required for 'patch'. Use empty string to delete matched text."}, ensure_ascii=False) + return tool_error("new_string is required for 'patch'. Use empty string to delete matched text.", success=False) result = _patch_skill(name, old_string, new_string, file_path, replace_all) elif action == "delete": @@ -604,14 +604,14 @@ def skill_manage( elif action == "write_file": if not file_path: - return json.dumps({"success": False, "error": "file_path is required for 'write_file'. Example: 'references/api-guide.md'"}, ensure_ascii=False) + return tool_error("file_path is required for 'write_file'. Example: 'references/api-guide.md'", success=False) if file_content is None: - return json.dumps({"success": False, "error": "file_content is required for 'write_file'."}, ensure_ascii=False) + return tool_error("file_content is required for 'write_file'.", success=False) result = _write_file(name, file_path, file_content) elif action == "remove_file": if not file_path: - return json.dumps({"success": False, "error": "file_path is required for 'remove_file'."}, ensure_ascii=False) + return tool_error("file_path is required for 'remove_file'.", success=False) result = _remove_file(name, file_path) else: @@ -722,7 +722,7 @@ SKILL_MANAGE_SCHEMA = { # --- Registry --- -from tools.registry import registry +from tools.registry import registry, tool_error registry.register( name="skill_manage", diff --git a/tools/skills_tool.py b/tools/skills_tool.py index c6b6cac3d..1c7182e83 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -76,7 +76,7 @@ from enum import Enum from pathlib import Path from typing import Dict, Any, List, Optional, Set, Tuple -from tools.registry import registry +from tools.registry import registry, tool_error logger = logging.getLogger(__name__) @@ -713,7 +713,7 @@ def skills_categories(verbose: bool = False, task_id: str = None) -> str: ) except Exception as e: - return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False) + return tool_error(str(e), success=False) def skills_list(category: str = None, task_id: str = None) -> str: @@ -781,7 +781,7 @@ def skills_list(category: str = None, task_id: str = None) -> str: ) except Exception as e: - return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False) + return tool_error(str(e), success=False) def skill_view(name: str, file_path: str = None, task_id: str = None) -> str: @@ -1255,7 +1255,7 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str: return json.dumps(result, ensure_ascii=False) except Exception as e: - return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False) + return tool_error(str(e), success=False) # Tool description for model_tools.py diff --git a/tools/todo_tool.py b/tools/todo_tool.py index d5dc33b50..9021fbc2d 100644 --- a/tools/todo_tool.py +++ b/tools/todo_tool.py @@ -161,7 +161,7 @@ def todo_tool( JSON string with the full current list and summary metadata. """ if store is None: - return json.dumps({"error": "TodoStore not initialized"}, ensure_ascii=False) + return tool_error("TodoStore not initialized") if todos is not None: items = store.write(todos, merge) @@ -255,7 +255,7 @@ TODO_SCHEMA = { # --- Registry --- -from tools.registry import registry +from tools.registry import registry, tool_error registry.register( name="todo", diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 4cb6e64ee..85fa4974d 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -466,7 +466,7 @@ def text_to_speech_tool( str: JSON result with success, file_path, and optionally MEDIA tag. """ if not text or not text.strip(): - return json.dumps({"success": False, "error": "Text is required"}, ensure_ascii=False) + return tool_error("Text is required", success=False) # Truncate very long text with a warning if len(text) > MAX_TEXT_LENGTH: @@ -607,17 +607,17 @@ def text_to_speech_tool( # Configuration errors (missing API keys, etc.) error_msg = f"TTS configuration error ({provider}): {e}" logger.error("%s", error_msg) - return json.dumps({"success": False, "error": error_msg}, ensure_ascii=False) + return tool_error(error_msg, success=False) except FileNotFoundError as e: # Missing dependencies or files error_msg = f"TTS dependency missing ({provider}): {e}" logger.error("%s", error_msg, exc_info=True) - return json.dumps({"success": False, "error": error_msg}, ensure_ascii=False) + return tool_error(error_msg, success=False) except Exception as e: # Unexpected errors error_msg = f"TTS generation failed ({provider}): {e}" logger.error("%s", error_msg, exc_info=True) - return json.dumps({"success": False, "error": error_msg}, ensure_ascii=False) + return tool_error(error_msg, success=False) # =========================================================================== @@ -950,7 +950,7 @@ if __name__ == "__main__": # --------------------------------------------------------------------------- # Registry # --------------------------------------------------------------------------- -from tools.registry import registry +from tools.registry import registry, tool_error TTS_SCHEMA = { "name": "text_to_speech", diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 8c9d6a9bc..2223032c3 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -320,7 +320,7 @@ async def vision_analyze_tool( try: from tools.interrupt import is_interrupted if is_interrupted(): - return json.dumps({"success": False, "error": "Interrupted"}) + return tool_error("Interrupted", success=False) logger.info("Analyzing image: %s", image_url[:60]) logger.info("User prompt: %s", user_prompt[:100]) @@ -570,7 +570,7 @@ if __name__ == "__main__": # --------------------------------------------------------------------------- # Registry # --------------------------------------------------------------------------- -from tools.registry import registry +from tools.registry import registry, tool_error VISION_ANALYZE_SCHEMA = { "name": "vision_analyze", diff --git a/tools/web_tools.py b/tools/web_tools.py index 8571c2a26..803a09c03 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -1079,7 +1079,7 @@ def web_search_tool(query: str, limit: int = 5) -> str: try: from tools.interrupt import is_interrupted if is_interrupted(): - return json.dumps({"error": "Interrupted", "success": False}) + return tool_error("Interrupted", success=False) # Dispatch to the configured backend backend = _get_backend() @@ -1158,7 +1158,7 @@ def web_search_tool(query: str, limit: int = 5) -> str: _debug.log_call("web_search_tool", debug_call_data) _debug.save() - return json.dumps({"error": error_msg}, ensure_ascii=False) + return tool_error(error_msg) async def web_extract_tool( @@ -1458,7 +1458,7 @@ async def web_extract_tool( trimmed_response = {"results": trimmed_results} if trimmed_response.get("results") == []: - result_json = json.dumps({"error": "Content was inaccessible or not found"}, ensure_ascii=False) + result_json = tool_error("Content was inaccessible or not found") cleaned_result = clean_base64_images(result_json) @@ -1484,7 +1484,7 @@ async def web_extract_tool( _debug.log_call("web_extract_tool", debug_call_data) _debug.save() - return json.dumps({"error": error_msg}, ensure_ascii=False) + return tool_error(error_msg) async def web_crawl_tool( @@ -1560,7 +1560,7 @@ async def web_crawl_tool( from tools.interrupt import is_interrupted as _is_int if _is_int(): - return json.dumps({"error": "Interrupted", "success": False}) + return tool_error("Interrupted", success=False) logger.info("Tavily crawl: %s", url) payload: Dict[str, Any] = { @@ -1671,7 +1671,7 @@ async def web_crawl_tool( from tools.interrupt import is_interrupted as _is_int if _is_int(): - return json.dumps({"error": "Interrupted", "success": False}) + return tool_error("Interrupted", success=False) try: crawl_result = _get_firecrawl_client().crawl( @@ -1897,7 +1897,7 @@ async def web_crawl_tool( _debug.log_call("web_crawl_tool", debug_call_data) _debug.save() - return json.dumps({"error": error_msg}, ensure_ascii=False) + return tool_error(error_msg) # Convenience function to check Firecrawl credentials @@ -2043,7 +2043,7 @@ if __name__ == "__main__": # --------------------------------------------------------------------------- # Registry # --------------------------------------------------------------------------- -from tools.registry import registry +from tools.registry import registry, tool_error WEB_SEARCH_SCHEMA = { "name": "web_search",