diff --git a/docs/tools.md b/docs/tools.md index 6c4a034..4130e2b 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -21,7 +21,6 @@ | `TodoTool` | `todo` | Per-session task checklist (set/update/read) | | `ScratchpadTool` | `scratchpad` | Per-session named working notes (write/append/read/clear) | | `ReloadToolsTool` | `reload_tools` | Hot-reload user tools without server restart | -| `WriteToolTool` | `write_tool` | Write a new user tool file and reload immediately | | `ListToolsTool` | `list_tools` | Return the live tool list from registry | | `ToolManualTool` | `tool_manual` | Return manuals/{name}.md or auto-generate from schema | | `MemoryTool` | `memory` | Unified memory tool: save, search, and forget facts | @@ -36,8 +35,6 @@ | `McpTool` (navi-web) | `mcp:navi-web:web_search` | Web search (SearXNG primary, DDG fallback, Brave tertiary) | | `McpTool` (navi-web) | `mcp:navi-web:web_view` | Open a URL in a headless browser and return clean readable text | | `McpTool` (navi-web) | `mcp:navi-web:http_request` | Raw HTTP request (GET/POST/PUT/PATCH/DELETE) | -| `DeleteToolTool` | `delete_tool` | Delete a user tool file | -| `TestToolTool` | `test_tool` | Run a user tool and verify its output | | `McpStatusTool` | `mcp_status` | Check connectivity and list tools for configured MCP servers | | `ReflectTool` | `reflect` | Self-reflection and analysis | | `ScheduleRecallTool` | `schedule_recall` | Schedule a headless callback for the current session (once/recurring/immediate) | @@ -45,7 +42,7 @@ ### User tools (`tools/*.py`) -Written by the agent via `write_tool` or manually. Auto-discovered at startup. +Written manually or via `create_mcp_server`. Auto-discovered at startup. - Files starting with `_` are ignored. - `tools/enabled.json` — list of user tool names to include in all profiles automatically. @@ -123,14 +120,15 @@ --- -## Self-extension via `write_tool` +## Self-extension via MCP servers -`WriteToolTool` validates the code before writing (checks for the 4 required definitions). On success: -1. Writes the file to `tools/{name}.py`. -2. Adds the name to `tools/enabled.json`. -3. Calls `reload_user_tools()` — tool is registered immediately. +New capabilities are added as MCP servers using `create_mcp_server`. The server scaffolding includes: +1. A directory under `mcp-servers/{name}/`. +2. A `server.py` entrypoint with stdio transport. +3. A config file at `mcp_servers.d/{name}.json`. +4. Registration via `reload_tools` or server restart. -The agent should call `tool_manual("write_tool")` before using `write_tool` for the first time — the manual at `manuals/write_tool.md` has the full format reference and a complete example. +The agent should call `tool_manual("create_mcp_server")` before using it for the first time. --- diff --git a/navi/core/registry.py b/navi/core/registry.py index e7f16a1..c1de963 100644 --- a/navi/core/registry.py +++ b/navi/core/registry.py @@ -9,7 +9,6 @@ from navi.tools import ( CodeExecTool, CreateMcpServerTool, - DeleteToolTool, FilesystemTool, ImageViewTool, ListProfilesTool, @@ -23,14 +22,12 @@ SwitchProfileTool, TerminalTool, TestMcpToolTool, - TestToolTool, TodoTool, Tool, ) from navi.tools.list_tools import ListToolsTool from navi.tools.reload_tools import ReloadToolsTool from navi.tools.tool_manual import ToolManualTool -from navi.tools.write_tool import WriteToolTool from navi.tools.share_file import ShareFileTool from navi.tools.content_publish import ContentPublishTool from navi.tools.mcp_status import McpStatusTool @@ -189,8 +186,6 @@ tools = ToolRegistry() reload_tool = ReloadToolsTool(registry=tools) - write_tool = WriteToolTool(registry=tools) - delete_tool = DeleteToolTool(registry=tools) list_tool = ListToolsTool(registry=tools) manual_tool = ToolManualTool(registry=tools) memory_tool = MemoryTool(memory_store) if memory_store else None @@ -202,9 +197,9 @@ builtins = [FilesystemTool(ai_helper=ai_helper), CodeExecTool(), TerminalTool(), SshExecTool(), ImageViewTool(), - ShareFileTool(), ContentPublishTool(), TestToolTool(), + ShareFileTool(), ContentPublishTool(), TodoTool(), ScratchpadTool(), ReflectTool(ai_helper=ai_helper), - reload_tool, write_tool, delete_tool, list_tool, manual_tool, + reload_tool, list_tool, manual_tool, mcp_status_tool, create_mcp_server_tool, test_mcp_tool_tool, schedule_recall_tool, manage_recall_tool] if memory_tool: diff --git a/navi/profiles/developer/config.json b/navi/profiles/developer/config.json index 44b6612..03cb890 100644 --- a/navi/profiles/developer/config.json +++ b/navi/profiles/developer/config.json @@ -6,7 +6,7 @@ "full_description": { "specialization": "Full-stack software development: writing code in any language, debugging, running tests, working with files and project structure, git, APIs, scripting. Works on the user's own projects, not Navi's internals.", "when_to_use": "When the user wants to build something — a game, a script, an app, a web service, anything. For writing Navi tools specifically, use tool_developer instead.", - "key_tools": "filesystem, code_exec, terminal, mcp:navi-web:web_search, mcp:navi-web:web_view, spawn_agent" + "key_tools": "filesystem, code_exec, terminal, ssh_exec, mcp:navi-web:web_search, mcp:navi-web:web_view, spawn_agent" }, "llm_backend": "ollama", "model": [ @@ -51,6 +51,8 @@ "image_view", "memory", "list_tools", + "tool_manual", + "ssh_exec", "spawn_agent", "share_file", "content_publish", diff --git a/navi/profiles/discuss/config.json b/navi/profiles/discuss/config.json index a44ad63..3d41119 100644 --- a/navi/profiles/discuss/config.json +++ b/navi/profiles/discuss/config.json @@ -21,6 +21,7 @@ "list_tools", "tool_manual", "switch_profile", + "list_profiles", "share_file", "filesystem", "schedule_recall", diff --git a/navi/profiles/tool_developer/config.json b/navi/profiles/tool_developer/config.json index a1c0d70..7371fcd 100644 --- a/navi/profiles/tool_developer/config.json +++ b/navi/profiles/tool_developer/config.json @@ -70,5 +70,12 @@ "planning_phase3_enabled": true, "top_k": 40, "top_p": 0.85, + "mcp_servers": { + "navi-web": [ + "search", + "browse", + "request" + ] + }, "num_thread": 11 } diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py index 2763ae1..db7bde1 100644 --- a/navi/tools/__init__.py +++ b/navi/tools/__init__.py @@ -1,7 +1,6 @@ from ._internal.base import Tool, ToolResult from .code_exec import CodeExecTool from .create_mcp_server import CreateMcpServerTool -from .delete_tool import DeleteToolTool from .filesystem import FilesystemTool from .image_view import ImageViewTool from .manage_recall import ManageRecallTool @@ -11,7 +10,6 @@ from .terminal import TerminalTool from .memory import MemoryTool from .test_mcp_tool import TestMcpToolTool -from .test_tool import TestToolTool from .todo import TodoTool from .scratchpad import ScratchpadTool from .switch_profile import SwitchProfileTool @@ -22,7 +20,6 @@ "Tool", "ToolResult", "CreateMcpServerTool", - "DeleteToolTool", "FilesystemTool", "CodeExecTool", "TerminalTool", @@ -32,7 +29,6 @@ "MemoryTool", "ScheduleRecallTool", "TestMcpToolTool", - "TestToolTool", "SpawnAgentTool", "TodoTool", "ScratchpadTool", diff --git a/navi/tools/delete_tool.py b/navi/tools/delete_tool.py deleted file mode 100644 index 15a7aed..0000000 --- a/navi/tools/delete_tool.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Built-in tool that removes a user tool by moving it to tools/.trash/.""" - -import json -from pathlib import Path - -from navi.config import settings - -from ._internal.base import Tool, ToolResult - -_TRASH_DIR_NAME = ".trash" - - -def _trash_dir(tools_dir: Path) -> Path: - d = tools_dir / _TRASH_DIR_NAME - d.mkdir(exist_ok=True) - return d - - -def _read_enabled(tools_dir: Path) -> list[str]: - f = tools_dir / "enabled.json" - try: - return json.loads(f.read_text()) if f.exists() else [] - except Exception: - return [] - - -def _write_enabled(tools_dir: Path, names: list[str]) -> None: - (tools_dir / "enabled.json").write_text(json.dumps(names, indent=2)) - - -class DeleteToolTool(Tool): - name = "delete_tool" - description = ( - "Remove, restore, or list trashed user tools. " - "remove — moves tools/.py to tools/.trash/ and unregisters it. " - "restore — moves it back and re-registers it. " - "list — shows what is currently in the trash." - ) - parameters = { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["remove", "restore", "list"], - "description": "remove: trash the tool. restore: recover from trash. list: show trash contents.", - }, - "name": { - "type": "string", - "description": "Tool name (filename without .py). Required for remove and restore.", - }, - }, - "required": ["action"], - } - - def __init__(self, registry=None) -> None: - self._registry = registry - - async def execute(self, params: dict) -> ToolResult: - action = params.get("action", "") - if action == "remove": - return await self._remove(params) - if action == "restore": - return await self._restore(params) - if action == "list": - return self._list() - return ToolResult(success=False, output=f"Unknown action '{action}'.", error="invalid action") - - async def _remove(self, params: dict) -> ToolResult: - name = (params.get("name") or "").strip().removesuffix(".py") - if not name: - return ToolResult(success=False, output="name is required for remove.", error="missing name") - - tools_dir = Path(settings.tools_dir) - src = tools_dir / f"{name}.py" - if not src.exists(): - return ToolResult(success=False, output=f"tools/{name}.py not found.", error="not found") - - dest = _trash_dir(tools_dir) / f"{name}.py" - if dest.exists(): - # avoid silent overwrite — rename with suffix - i = 1 - while dest.exists(): - dest = _trash_dir(tools_dir) / f"{name}.{i}.py" - i += 1 - - src.rename(dest) - - # Remove from enabled.json - enabled = _read_enabled(tools_dir) - if name in enabled: - enabled.remove(name) - _write_enabled(tools_dir, enabled) - - # Reload registry to unregister - notes = [] - if self._registry is not None: - self._registry.reload_user_tools(settings.tools_dir) - notes.append("unregistered from registry") - else: - notes.append("registry unavailable — restart to fully unregister") - - return ToolResult( - success=True, - output=f"Moved tools/{name}.py → tools/.trash/{dest.name}. " - f"Removed from enabled.json. {', '.join(notes).capitalize()}.", - ) - - async def _restore(self, params: dict) -> ToolResult: - name = (params.get("name") or "").strip().removesuffix(".py") - if not name: - return ToolResult(success=False, output="name is required for restore.", error="missing name") - - tools_dir = Path(settings.tools_dir) - trash = _trash_dir(tools_dir) - src = trash / f"{name}.py" - if not src.exists(): - trashed = [p.name for p in trash.glob("*.py")] - hint = f" Trash contains: {', '.join(trashed)}" if trashed else " Trash is empty." - return ToolResult(success=False, output=f"tools/.trash/{name}.py not found.{hint}", error="not found") - - dest = tools_dir / f"{name}.py" - if dest.exists(): - return ToolResult( - success=False, - output=f"tools/{name}.py already exists. Remove or rename it first.", - error="conflict", - ) - - src.rename(dest) - - # Add back to enabled.json - enabled = _read_enabled(tools_dir) - if name not in enabled: - enabled.append(name) - _write_enabled(tools_dir, enabled) - - # Reload registry - notes = [] - if self._registry is not None: - self._registry.reload_user_tools(settings.tools_dir) - notes.append("re-registered in registry") - else: - notes.append("registry unavailable — restart to register") - - return ToolResult( - success=True, - output=f"Restored tools/.trash/{name}.py → tools/{name}.py. " - f"Added to enabled.json. {', '.join(notes).capitalize()}.", - ) - - def _list(self) -> ToolResult: - tools_dir = Path(settings.tools_dir) - trash = _trash_dir(tools_dir) - files = sorted(trash.glob("*.py")) - if not files: - return ToolResult(success=True, output="Trash is empty.") - lines = [f.name for f in files] - return ToolResult(success=True, output=f"{len(lines)} file(s) in trash:\n" + "\n".join(lines)) diff --git a/navi/tools/memory_forget.py b/navi/tools/memory_forget.py deleted file mode 100644 index 0946434..0000000 --- a/navi/tools/memory_forget.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Memory forget tool — delete a fact from long-term memory.""" - -from navi.memory.store import MemoryStore -from navi.tools._internal.base import current_user_id - -from ._internal.base import Tool, ToolResult - - -class MemoryForgetTool(Tool): - name = "memory_forget" - description = ( - "Delete a fact from your long-term memory. " - "Use when the user explicitly asks you to forget something, " - "or when you know a stored fact is outdated or incorrect." - ) - parameters = { - "type": "object", - "properties": { - "key": { - "type": "string", - "description": "The key of the fact to delete (e.g. 'location', 'employer', 'home_server')", - }, - "category": { - "type": "string", - "description": "Optional: narrow by category (profile, preferences, technical, projects, other)", - }, - }, - "required": ["key"], - } - - def __init__(self, memory_store: MemoryStore) -> None: - self._store = memory_store - - async def execute(self, params: dict) -> ToolResult: - key = params.get("key", "").strip() - category = params.get("category", "").strip() or None - - if not key: - return ToolResult(success=False, output="Key is required.", error="missing key") - - user_id = current_user_id.get(None) - deleted = await self._store.delete_fact(key, category, user_id=user_id) - if deleted == 0: - return ToolResult(success=False, output=f"No fact found with key '{key}'.", error="not found") - - noun = "fact" if deleted == 1 else "facts" - return ToolResult(success=True, output=f"Deleted {deleted} {noun} with key '{key}'.") diff --git a/navi/tools/memory_save.py b/navi/tools/memory_save.py deleted file mode 100644 index 8180218..0000000 --- a/navi/tools/memory_save.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Memory save tool — persist a fact about the user to long-term memory.""" - -from navi.memory.store import MemoryStore -from navi.tools._internal.base import current_session_id, current_user_id - -from ._internal.base import Tool, ToolResult - -_VALID_CATEGORIES = {"profile", "preferences", "technical", "projects", "other"} - - -class MemorySaveTool(Tool): - name = "memory_save" - description = ( - "Save or update a fact about the user in long-term memory. " - "Use this when the user tells you something stable and reusable — name, location, " - "preferences, ongoing projects, technical environment, recurring workflows, etc. " - "Also use it to correct an outdated fact you know is wrong. " - "Facts survive across sessions and are available via memory_search." - ) - parameters = { - "type": "object", - "properties": { - "category": { - "type": "string", - "enum": ["profile", "preferences", "technical", "projects", "other"], - "description": ( - "Broad category for the fact. " - "profile=who they are, preferences=what they like/dislike, " - "technical=OS/tools/servers/languages, projects=ongoing work, other=anything else." - ), - }, - "key": { - "type": "string", - "description": ( - "Short snake_case identifier, unique within the category. " - "Examples: 'name', 'primary_os', 'home_server_ip', 'preferred_language', " - "'current_project', 'response_language'." - ), - }, - "value": { - "type": "string", - "description": "The fact itself, written as a concise plain-text statement.", - }, - }, - "required": ["category", "key", "value"], - } - - def __init__(self, memory_store: MemoryStore) -> None: - self._store = memory_store - - async def execute(self, params: dict) -> ToolResult: - category = params.get("category", "").strip().lower() - key = params.get("key", "").strip() - value = params.get("value", "").strip() - - if not category: - return ToolResult(success=False, output="category is required.", error="missing category") - if category not in _VALID_CATEGORIES: - return ToolResult( - success=False, - output=f"Invalid category '{category}'. Must be one of: {', '.join(sorted(_VALID_CATEGORIES))}", - error="invalid category", - ) - if not key: - return ToolResult(success=False, output="key is required.", error="missing key") - if not value: - return ToolResult(success=False, output="value is required.", error="missing value") - - session_id = current_session_id.get(None) - user_id = current_user_id.get(None) - await self._store.upsert_fact(category, key, value, user_id=user_id, source_session_id=session_id) - return ToolResult(success=True, output=f"Saved [{category}] {key}: {value}") diff --git a/navi/tools/memory_search.py b/navi/tools/memory_search.py deleted file mode 100644 index 2188a52..0000000 --- a/navi/tools/memory_search.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Memory search tool — query facts about the user from long-term memory.""" - -from navi.memory.store import MemoryStore -from navi.tools._internal.base import current_user_id - -from ._internal.base import Tool, ToolResult - - -class MemorySearchTool(Tool): - name = "memory_search" - description = ( - "Search long-term memory for facts about the user. " - "Call this when the injected summary does not contain the specific information you need: " - "server addresses, credentials, preferences, project details, past decisions, ongoing work. " - "Returns matching facts from the memory database." - ) - parameters = { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": ( - "What to search for — describe the context you need. " - "Examples: 'user profile', 'home server', 'programming preferences', 'current projects'" - ), - }, - }, - "required": ["query"], - } - - def __init__(self, memory_store: MemoryStore) -> None: - self._store = memory_store - - async def execute(self, params: dict) -> ToolResult: - query = params.get("query", "").strip() - if not query: - return ToolResult(success=False, output="Query is required.", error="missing query") - - user_id = current_user_id.get(None) - facts = await self._store.search_facts(query, user_id=user_id, limit=15) - if not facts: - return ToolResult(success=True, output="No matching facts found in memory.") - - lines = [f"[{f['category']}] {f['key']}: {f['value']}" for f in facts] - output = f"Found {len(facts)} fact(s):\n" + "\n".join(lines) - return ToolResult(success=True, output=output) diff --git a/navi/tools/test_tool.py b/navi/tools/test_tool.py deleted file mode 100644 index 017e9f7..0000000 --- a/navi/tools/test_tool.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Test tool — run a user tool's execute() in isolation and return the result or traceback.""" - -import importlib -import importlib.util -import sys -import traceback -from pathlib import Path - -from navi.config import settings - -from ._internal.base import Tool, ToolResult - - -class TestToolTool(Tool): - name = "test_tool" - description = ( - "Run a user tool's execute() function with given params and return the result or full traceback. " - "Always use this after writing or editing a tool file to verify it works before calling reload_tools." - ) - parameters = { - "type": "object", - "properties": { - "tool_name": { - "type": "string", - "description": "Name of the tool to test (filename without .py, e.g. 'my_tool').", - }, - "params": { - "type": "object", - "description": "Parameters dict to pass to execute(). Omit or pass {} for tools with no required params.", - }, - }, - "required": ["tool_name"], - } - - async def execute(self, params: dict) -> ToolResult: - tool_name = (params.get("tool_name") or "").strip() - test_params: dict = params.get("params") or {} - - if not tool_name: - return ToolResult(success=False, output="tool_name is required.", error="missing tool_name") - - tool_path = Path(settings.tools_dir) / f"{tool_name}.py" - if not tool_path.exists(): - return ToolResult( - success=False, - output=f"File not found: {tool_path}", - error="file not found", - ) - - # Force a fresh import from disk — bypasses any cached (possibly stale) module - module_key = f"_test_tool_run_{tool_name}" - spec = importlib.util.spec_from_file_location(module_key, tool_path) - if spec is None or spec.loader is None: - return ToolResult(success=False, output=f"Cannot load spec for {tool_path}", error="spec error") - - module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) # type: ignore[union-attr] - except Exception: - tb = traceback.format_exc() - return ToolResult(success=False, output=f"Module load error:\n{tb}", error="load error") - - execute_fn = getattr(module, "execute", None) - if execute_fn is None: - return ToolResult(success=False, output="Module has no execute() function.", error="missing execute") - - try: - result = await execute_fn(test_params) - except Exception: - tb = traceback.format_exc() - return ToolResult(success=False, output=f"execute() raised an exception:\n{tb}", error="runtime error") - - return ToolResult(success=True, output=f"OK\n\nResult: {result}") diff --git a/navi/tools/tool_manual.py b/navi/tools/tool_manual.py index 850ca9f..12a88ec 100644 --- a/navi/tools/tool_manual.py +++ b/navi/tools/tool_manual.py @@ -18,7 +18,7 @@ "properties": { "tool_name": { "type": "string", - "description": "Name of the tool to look up, e.g. 'write_tool'", + "description": "Name of the tool to look up, e.g. 'create_mcp_server'", } }, "required": ["tool_name"], diff --git a/navi/tools/write_tool.py b/navi/tools/write_tool.py deleted file mode 100644 index bb6c5ea..0000000 --- a/navi/tools/write_tool.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Built-in tool that writes a new user tool file and immediately reloads it. - -This is the primary way for the agent to extend itself with new capabilities. -""" - -import asyncio -import json -from pathlib import Path - -from navi.config import settings - -from ._internal.base import Tool, ToolResult - - -def _register_user_tool(tool_name: str, tools_dir: Path) -> None: - """Add tool_name to tools/enabled.json so it's auto-included in all profiles.""" - enabled_file = tools_dir / "enabled.json" - try: - existing: list[str] = json.loads(enabled_file.read_text()) if enabled_file.exists() else [] - except Exception: - existing = [] - if tool_name not in existing: - existing.append(tool_name) - enabled_file.write_text(json.dumps(existing, indent=2)) - - -class WriteToolTool(Tool): - name = "write_tool" - description = ( - "Create a new permanent tool for yourself. " - "Writes the code to tools/.py and immediately reloads it — no server restart needed. " - "Use this whenever you need a capability you don't have yet: task tracking, reminders, " - "external APIs, data storage, calculations, etc. " - "The tool will be available in every future session, not just this one. " - "Read tools/_template.py first to see the exact required format." - ) - parameters = { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Tool filename without .py extension, e.g. 'task_manager'. Also used as the tool's name attribute.", - }, - "code": { - "type": "string", - "description": ( - "Full Python source code for the tool. Must define exactly: " - "name (str), description (str), parameters (JSON Schema dict), " - "async def execute(params: dict) -> str. " - "No classes. No module-level print(). Return plain string. Raise on error." - ), - }, - }, - "required": ["name", "code"], - } - - def __init__(self, registry=None) -> None: - self._registry = registry - - async def execute(self, params: dict) -> ToolResult: - tool_name = params["name"].strip().removesuffix(".py") - code = params["code"] - - if not tool_name or tool_name.startswith("_"): - return ToolResult(success=False, output="Tool name must not be empty or start with '_'.", error="invalid_name") - - missing = [kw for kw in ("name", "description", "parameters", "async def execute") if kw not in code] - if missing: - example = 'name = "tool_name"\ndescription = "..."\nparameters = {"type": "object", "properties": {}, "required": []}\n\nasync def execute(params: dict) -> str:\n ...' - return ToolResult( - success=False, - output=( - f"Code rejected — missing required module-level definitions: {missing}.\n\n" - f"Every tool file must start with these four definitions before any other code:\n{example}\n\n" - f"Your execute() function logic can follow after. Also make sure execute is async." - ), - error="invalid_code", - ) - - tools_dir = Path(settings.tools_dir) - tools_dir.mkdir(exist_ok=True) - file_path = tools_dir / f"{tool_name}.py" - - try: - await asyncio.to_thread(file_path.write_text, code, "utf-8") - except OSError as e: - return ToolResult(success=False, output=f"Failed to write file: {e}", error="write_error") - - if self._registry is None: - return ToolResult(success=False, output=f"Written to {file_path}, but registry unavailable for reload.", error="no_registry") - - result = self._registry.reload_user_tools(settings.tools_dir) - - lines = [] - if result.loaded: - lines.append(f"Loaded ({len(result.loaded)}): {', '.join(t.name for t in result.loaded)}") - if result.errors: - lines.append(f"\nErrors ({len(result.errors)}):") - for filename, error in result.errors.items(): - lines.append(f" {filename}: {error}") - - if file_path.name in result.errors: - return ToolResult( - success=False, - output=f"File written but failed to load:\n {result.errors[file_path.name]}\n\nFix the code and call write_tool again.", - error="load_error", - ) - - loaded_names = [t.name for t in result.loaded] - if tool_name in loaded_names: - await asyncio.to_thread(_register_user_tool, tool_name, tools_dir) - return ToolResult(success=True, output=f"Tool '{tool_name}' created and enabled globally.\n" + "\n".join(lines)) - - return ToolResult(success=True, output="\n".join(lines) if lines else "Done.")