"""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/<name>.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.")