""" Orchestrator tool registry. Declarations live in each domain module alongside their callables. This file assembles them into the unified registry used by both engines. To add a new tool: 1. Implement it in tools/.py — add the async callable + append to DECLARATIONS 2. Import the callable here and add it to _CALLABLES 3. If admin-only, add it to TOOL_ROLES; if confirmation needed, add to CONFIRM_REQUIRED IMPORTANT: These tools are separate from the ae_* MCP tools used by the fleet agents. Do not modify the ae_* MCP server to support orchestrator needs. """ from google.genai import types # ── Callable imports ────────────────────────────────────────────────────────── from tools.web import search as _web_search, http_fetch as _http_fetch, web_read as _web_read, http_post as _http_post from tools.ae_knowledge import ( journal_list as _ae_journal_list, journal_search as _ae_journal_search, journal_entry_read as _ae_journal_entry_read, journal_entries_list as _ae_journal_entries_list, journal_entry_create as _ae_journal_entry_create, journal_entry_update as _ae_journal_entry_update, journal_entry_disable as _ae_journal_entry_disable, journal_entry_append as _ae_journal_entry_append, journal_entry_prepend as _ae_journal_entry_prepend, ) from tools.ae_tasks import task_list as _ae_task_list from tools.files import ( project_file_read as _project_file_read, project_file_list as _project_file_list, file_stat as _file_stat, file_grep as _file_grep, file_diff as _file_diff, file_syntax_check as _file_syntax_check, file_read as _file_read, file_list as _file_list, file_write as _file_write, session_read as _session_read, session_search as _session_search, ) from tools.system import ( shell_exec as _shell_exec, claude_allow_dir as _claude_allow_dir, cortex_restart as _cortex_restart, cortex_logs as _cortex_logs, cortex_status as _cortex_status, cortex_update as _cortex_update, ) from tools.tasks import ( task_list as _task_list, task_create as _task_create, task_update as _task_update, task_complete as _task_complete, ) from tools.cron import ( cron_list as _cron_list, cron_add as _cron_add, cron_remove as _cron_remove, cron_toggle as _cron_toggle, ) from tools.reminders import ( reminders_add as _reminders_add, reminders_list as _reminders_list, reminders_remove as _reminders_remove, reminders_clear as _reminders_clear, ) from tools.scratch import ( scratch_read as _scratch_read, scratch_write as _scratch_write, scratch_append as _scratch_append, scratch_clear as _scratch_clear, ) from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send, web_push as _web_push, nc_talk_history as _nc_talk_history from tools.agent_notes import ( agent_notes_read as _agent_notes_read, agent_notes_write as _agent_notes_write, agent_notes_append as _agent_notes_append, agent_notes_clear as _agent_notes_clear, ) from tools.git import ( git_status as _git_status, git_log as _git_log, git_diff as _git_diff, ) from tools.agents import ( spawn_agent as _spawn_agent, agent_status as _agent_status, agent_list as _agent_list, agent_cancel as _agent_cancel, ) from tools.aider import aider_run as _aider_run from tools.homeassistant import ( ha_get_state as _ha_get_state, ha_get_states as _ha_get_states, ha_call_service as _ha_call_service, ) from tools.ae_database import ( ae_db_query as _ae_db_query, ae_db_describe as _ae_db_describe, ae_db_show_view as _ae_db_show_view, ) # ── Declaration imports ─────────────────────────────────────────────────────── import tools.web as _mod_web import tools.ae_knowledge as _mod_ae_knowledge import tools.ae_tasks as _mod_ae_tasks import tools.files as _mod_files import tools.system as _mod_system import tools.tasks as _mod_tasks import tools.cron as _mod_cron import tools.reminders as _mod_reminders import tools.scratch as _mod_scratch import tools.notify as _mod_notify import tools.agent_notes as _mod_agent_notes import tools.git as _mod_git import tools.agents as _mod_agents import tools.aider as _mod_aider import tools.homeassistant as _mod_homeassistant import tools.ae_database as _mod_ae_database # ── Tool categories — used by the Model Registry UI for grouped checkboxes ─── TOOL_CATEGORIES: dict[str, list[str]] = { "Web": ["web_search", "http_fetch", "web_read", "http_post"], "Project Files": ["project_file_read", "project_file_list", "file_stat", "file_grep", "file_diff", "file_syntax_check"], "Git": ["git_status", "git_log", "git_diff"], "System Files": ["file_read", "file_list", "file_write", "session_read", "session_search"], "Shell": ["shell_exec", "claude_allow_dir"], "System": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update"], "Tasks": ["task_list", "task_create", "task_update", "task_complete"], "Cron": ["cron_list", "cron_add", "cron_remove", "cron_toggle"], "Reminders": ["reminders_add", "reminders_list", "reminders_remove", "reminders_clear"], "Scratchpad": ["scratch_read", "scratch_write", "scratch_append", "scratch_clear"], "Notifications": ["web_push", "email_send", "nc_talk_send", "nc_talk_history"], "Aether Journals": [ "ae_journal_list", "ae_journal_search", "ae_journal_entries_list", "ae_journal_entry_read", "ae_journal_entry_create", "ae_journal_entry_update", "ae_journal_entry_disable", "ae_journal_entry_append", "ae_journal_entry_prepend", ], "Aether Tasks": ["ae_task_list"], "Agent Notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"], "Agents": ["spawn_agent", "agent_status", "agent_list", "agent_cancel", "aider_run"], "Home Assistant": ["ha_get_state", "ha_get_states", "ha_call_service"], "Aether Database": ["ae_db_query", "ae_db_describe", "ae_db_show_view"], } # ── Callable registry ───────────────────────────────────────────────────────── _CALLABLES: dict[str, callable] = { "web_search": _web_search, "http_fetch": _http_fetch, "web_read": _web_read, "http_post": _http_post, "ae_journal_list": _ae_journal_list, "ae_journal_search": _ae_journal_search, "ae_journal_entry_read": _ae_journal_entry_read, "ae_journal_entries_list": _ae_journal_entries_list, "ae_journal_entry_create": _ae_journal_entry_create, "ae_journal_entry_update": _ae_journal_entry_update, "ae_journal_entry_disable": _ae_journal_entry_disable, "ae_journal_entry_append": _ae_journal_entry_append, "ae_journal_entry_prepend": _ae_journal_entry_prepend, "ae_task_list": _ae_task_list, "project_file_read": _project_file_read, "project_file_list": _project_file_list, "file_stat": _file_stat, "file_grep": _file_grep, "file_diff": _file_diff, "file_syntax_check": _file_syntax_check, "file_read": _file_read, "file_list": _file_list, "file_write": _file_write, "session_read": _session_read, "session_search": _session_search, "shell_exec": _shell_exec, "claude_allow_dir": _claude_allow_dir, "cortex_restart": _cortex_restart, "cortex_logs": _cortex_logs, "cortex_status": _cortex_status, "cortex_update": _cortex_update, "task_list": _task_list, "task_create": _task_create, "task_update": _task_update, "task_complete": _task_complete, "cron_list": _cron_list, "cron_add": _cron_add, "cron_remove": _cron_remove, "cron_toggle": _cron_toggle, "reminders_add": _reminders_add, "reminders_list": _reminders_list, "reminders_remove": _reminders_remove, "reminders_clear": _reminders_clear, "scratch_read": _scratch_read, "scratch_write": _scratch_write, "scratch_append": _scratch_append, "scratch_clear": _scratch_clear, "email_send": _email_send, "nc_talk_send": _nc_talk_send, "web_push": _web_push, "nc_talk_history": _nc_talk_history, "agent_notes_read": _agent_notes_read, "agent_notes_write": _agent_notes_write, "agent_notes_append": _agent_notes_append, "agent_notes_clear": _agent_notes_clear, "git_status": _git_status, "git_log": _git_log, "git_diff": _git_diff, "spawn_agent": _spawn_agent, "agent_status": _agent_status, "agent_list": _agent_list, "agent_cancel": _agent_cancel, "aider_run": _aider_run, "ha_get_state": _ha_get_state, "ha_get_states": _ha_get_states, "ha_call_service": _ha_call_service, "ae_db_query": _ae_db_query, "ae_db_describe": _ae_db_describe, "ae_db_show_view": _ae_db_show_view, } # ── Role-based access control ───────────────────────────────────────────────── # Minimum role required to use each tool. Unlisted tools default to "user". TOOL_ROLES: dict[str, str] = { "shell_exec": "admin", "claude_allow_dir": "admin", "cortex_restart": "admin", "cortex_logs": "admin", "cortex_status": "admin", "cortex_update": "admin", "file_read": "admin", "file_list": "admin", "file_write": "admin", "ae_task_list": "admin", "spawn_agent": "admin", "agent_status": "user", "agent_list": "user", "agent_cancel": "admin", "aider_run": "admin", "email_send": "admin", "nc_talk_send": "admin", "http_post": "admin", "nc_talk_history": "admin", "ha_call_service": "admin", "ae_db_query": "admin", "ae_db_describe": "admin", "ae_db_show_view": "admin", } # Tools that require explicit user confirmation before executing. CONFIRM_REQUIRED: set[str] = { "cortex_restart", "cortex_update", "file_write", "shell_exec", "cron_remove", "reminders_clear", "http_post", "ha_call_service", "ae_journal_entry_disable", # disables a journal entry — not easily reversed "agent_cancel", # kills a running background task "aider_run", # edits files and commits — irreversible without git revert } # Security risk ratings — informational for now; will drive auto-allow tiers later. # Unlisted tools default to "medium". # # low — read-only, sandboxed, no external side effects # medium — writes to local/controlled data, or reads beyond project scope, # or sends notifications to the same user # high — affects external systems, physical devices, other users, # or the host process/filesystem in ways that are hard to reverse TOOL_RISK: dict[str, str] = { # Web — read-only fetches are low; posting to external services is high "web_search": "low", "http_fetch": "low", "web_read": "low", "http_post": "high", # Project Files — all read-only and project-sandboxed "project_file_read": "low", "project_file_list": "low", "file_stat": "low", "file_grep": "low", "file_diff": "low", "file_syntax_check": "low", # System Files — reads beyond project scope are medium; writes are high "file_read": "medium", "file_list": "medium", "file_write": "high", "session_read": "low", "session_search": "low", # Shell — arbitrary execution and permission changes are high "shell_exec": "high", "claude_allow_dir": "high", # System — read-only status is low; restart/update affect the live service "cortex_logs": "low", "cortex_status": "low", "cortex_restart": "high", "cortex_update": "high", # Tasks — local persona data, all reversible "task_list": "low", "task_create": "low", "task_update": "low", "task_complete": "low", # Cron — list is low; add/remove/toggle affect scheduled behavior "cron_list": "low", "cron_add": "medium", "cron_remove": "medium", "cron_toggle": "medium", # Reminders — single-item ops are low; clear-all is medium "reminders_add": "low", "reminders_list": "low", "reminders_remove": "low", "reminders_clear": "medium", # Scratchpad — local persona file, ephemeral by design "scratch_read": "low", "scratch_write": "low", "scratch_append": "low", "scratch_clear": "low", # Notifications — push to same user is medium; external messages are high "web_push": "medium", "nc_talk_send": "high", "nc_talk_history": "low", "email_send": "high", # Aether Journals — reads are low; writes to external DB are medium "ae_journal_list": "low", "ae_journal_search": "low", "ae_journal_entries_list": "low", "ae_journal_entry_read": "low", "ae_journal_entry_create": "medium", "ae_journal_entry_update": "medium", "ae_journal_entry_disable": "medium", "ae_journal_entry_append": "medium", "ae_journal_entry_prepend": "medium", # Aether Tasks "ae_task_list": "low", # Agent Notes — local persona file "agent_notes_read": "low", "agent_notes_write": "low", "agent_notes_append": "low", "agent_notes_clear": "low", # Git — all read-only inspections "git_status": "low", "git_log": "low", "git_diff": "low", # Agents — spawning is high; lifecycle reads are low; cancel is medium (kills a task) "spawn_agent": "high", "agent_status": "low", "agent_list": "low", "agent_cancel": "medium", "aider_run": "high", # Home Assistant — reads are low; controlling physical devices is high "ha_get_state": "low", "ha_get_states": "low", "ha_call_service": "high", # Aether Database — all read-only; query reads data, describe/show_view read schema only "ae_db_query": "medium", "ae_db_describe": "low", "ae_db_show_view": "low", } _RISK_RANK: dict[str, int] = {"low": 0, "medium": 1, "high": 2} _ROLE_RANK: dict[str, int] = {"user": 0, "admin": 1} def _role_allowed(tool_name: str, role: str) -> bool: required = TOOL_ROLES.get(tool_name, "user") return _ROLE_RANK.get(role, 0) >= _ROLE_RANK.get(required, 0) # ── Declaration assembly ────────────────────────────────────────────────────── _ALL_DECLARATIONS: list[types.FunctionDeclaration] = ( _mod_web.DECLARATIONS + _mod_files.DECLARATIONS + _mod_git.DECLARATIONS + _mod_system.DECLARATIONS + _mod_tasks.DECLARATIONS + _mod_cron.DECLARATIONS + _mod_reminders.DECLARATIONS + _mod_scratch.DECLARATIONS + _mod_notify.DECLARATIONS + _mod_ae_knowledge.DECLARATIONS + _mod_ae_tasks.DECLARATIONS + _mod_agent_notes.DECLARATIONS + _mod_agents.DECLARATIONS + _mod_aider.DECLARATIONS + _mod_homeassistant.DECLARATIONS + _mod_ae_database.DECLARATIONS ) # Full Gemini Tool object (all tools — use get_tools_for_role() in production) TOOL_DECLARATIONS = [types.Tool(function_declarations=_ALL_DECLARATIONS)] # ── Tool dispatch ───────────────────────────────────────────────────────────── async def call_tool(name: str, args: dict, callables: dict | None = None) -> str: """Dispatch a tool call by name. Returns result as a string. Pass `callables` (from get_tools_for_role) to enforce role restrictions. Falls back to the full _CALLABLES dict if omitted. Every call is recorded to the tool audit log (tool_audit.py). """ import asyncio import tool_audit from persona import get_user user = get_user() or "unknown" dispatch = callables if callables is not None else _CALLABLES fn = dispatch.get(name) if fn is None: asyncio.create_task(tool_audit.record(user, name, args, "denied")) return f"Tool not available or access denied: {name}" try: result = await fn(**args) asyncio.create_task(tool_audit.record(user, name, args, "ok", result)) return result except Exception as e: asyncio.create_task(tool_audit.record(user, name, args, "error", str(e))) raise # ── OpenAI JSON Schema conversion ──────────────────────────────────────────── _GEMINI_TYPE_TO_JSON = { "OBJECT": "object", "STRING": "string", "INTEGER": "integer", "NUMBER": "number", "BOOLEAN": "boolean", "ARRAY": "array", } def _schema_to_json(schema) -> dict: """Recursively convert a Gemini types.Schema to a JSON Schema dict.""" type_name = getattr(getattr(schema, "type", None), "name", "STRING") result: dict = {"type": _GEMINI_TYPE_TO_JSON.get(type_name, "string")} if getattr(schema, "description", None): result["description"] = schema.description props = getattr(schema, "properties", None) or {} if result["type"] == "object": result["properties"] = {k: _schema_to_json(v) for k, v in props.items()} req = getattr(schema, "required", None) if req: result["required"] = list(req) return result def _build_openai_tools() -> list[dict]: out = [] for decl in _ALL_DECLARATIONS: params = ( _schema_to_json(decl.parameters) if decl.parameters else {"type": "object", "properties": {}} ) out.append({ "type": "function", "function": { "name": decl.name, "description": decl.description or "", "parameters": params, }, }) return out # OpenAI-format tool list — all tools (use get_openai_tools_for_role() in production) OPENAI_TOOL_SCHEMAS: list[dict] = _build_openai_tools() # ── Role-filtered tool access ───────────────────────────────────────────────── def _apply_risk_policy( allowed: set[str], max_risk: str | None, whitelist: list[str] | None, blacklist: list[str] | None, ) -> set[str]: """Apply risk-level filtering on top of an already role-gated allowed set. Filtering order (each step can only restrict or restore within what the role already permits — risk policy can never elevate above role): 1. max_risk auto-include: keep tools whose risk ≤ max_risk 2. whitelist union: force-add specific tools (still role-gated) 3. blacklist subtract: force-remove specific tools When max_risk is None, all role-allowed tools remain (no risk filter). """ if max_risk is not None: max_rank = _RISK_RANK.get(max_risk, 2) auto = {n for n in allowed if _RISK_RANK.get(TOOL_RISK.get(n, "medium"), 1) <= max_rank} extra = {n for n in (whitelist or []) if n in allowed} allowed = (auto | extra) if blacklist: allowed -= set(blacklist) return allowed def get_tools_for_role( role: str, tool_list: list[str] | None = None, max_risk: str | None = None, whitelist: list[str] | None = None, blacklist: list[str] | None = None, ) -> tuple[list, dict]: """Return (gemini_tool_declarations, callables_dict) filtered to what the role can use. role — user access level ("user" | "admin"); gates admin-only tools tool_list — optional model-level allow-list; intersected so it can only restrict max_risk — auto-include tools at/below this risk level ("low"|"medium"|"high") whitelist — force-include specific tools above max_risk (still role-gated) blacklist — force-exclude specific tools regardless of max_risk """ allowed = {name for name in _CALLABLES if _role_allowed(name, role)} allowed = _apply_risk_policy(allowed, max_risk, whitelist, blacklist) if tool_list is not None: allowed &= set(tool_list) decls = [d for d in _ALL_DECLARATIONS if d.name in allowed] callables = {k: v for k, v in _CALLABLES.items() if k in allowed} return [types.Tool(function_declarations=decls)], callables def get_openai_tools_for_role( role: str, tool_list: list[str] | None = None, max_risk: str | None = None, whitelist: list[str] | None = None, blacklist: list[str] | None = None, ) -> list[dict]: """Return OpenAI tool schemas filtered to what the role can use. role — user access level ("user" | "admin") tool_list — optional model-level allow-list max_risk — auto-include tools at/below this risk level whitelist — force-include specific tools above max_risk blacklist — force-exclude specific tools """ allowed = {name for name in _CALLABLES if _role_allowed(name, role)} allowed = _apply_risk_policy(allowed, max_risk, whitelist, blacklist) if tool_list is not None: allowed &= set(tool_list) return [t for t in OPENAI_TOOL_SCHEMAS if t["function"]["name"] in allowed] # ── Keyword-based tool routing ───────────────────────────────────────────────── # Maps classifier category names → tool names in that category CATEGORY_TOOL_MAP: dict[str, list[str]] = { "web": ["web_search", "web_read", "http_fetch"], "web_post": ["http_post"], "file": ["project_file_read", "project_file_list", "file_stat", "file_grep", "file_diff", "file_syntax_check", "file_read", "file_list", "file_write"], "git": ["git_status", "git_log", "git_diff"], "system": ["cortex_restart", "cortex_logs", "cortex_status", "cortex_update", "shell_exec"], "tasks": ["task_list", "task_create", "task_update", "task_complete"], "cron": ["cron_list", "cron_add", "cron_remove", "cron_toggle"], "reminders": ["reminders_add", "reminders_list", "reminders_remove", "reminders_clear"], "scratchpad": ["scratch_read", "scratch_write", "scratch_append", "scratch_clear"], "ha": ["ha_get_state", "ha_get_states", "ha_call_service"], "aether": ["ae_journal_list", "ae_journal_search", "ae_journal_entries_list", "ae_journal_entry_read", "ae_journal_entry_create", "ae_journal_entry_update", "ae_journal_entry_disable", "ae_journal_entry_append", "ae_journal_entry_prepend"], "aether_db": ["ae_db_query", "ae_db_describe", "ae_db_show_view"], "notifications":["web_push", "email_send", "nc_talk_send", "nc_talk_history"], "agents": ["spawn_agent", "agent_status", "agent_list", "agent_cancel", "aider_run"], "notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"], "session": ["session_read", "session_search"], "ae_tasks": ["ae_task_list"], "claude": ["claude_allow_dir"], } _KEYWORD_CATEGORY_MAP: dict[str, list[str]] = { "web": ["search", "look up", "what is", "who is", "weather", "forecast", "news", "find on", "google", "website", "article", "research", "temperature"], "web_post": ["post to", "send to", "webhook", "trigger webhook"], "file": ["read file", "show file", "list file", "directory", "grep", "search in", "find in", "diff", "compare", "syntax check", "open file"], "git": ["git", "commit", "branch", "pulled", "merged", "repository", "repo"], "system": ["restart", "update", "status", "logs", "log", "deploy", "run command", "shell", "is it running", "health"], "tasks": ["task", "todo", "to-do", "to do", "add task", "create task", "pending", "what's on my list"], "cron": ["schedule", "cron", "every day", "every week", "recurring", "automate", "job"], "reminders": ["remind", "reminder", "don't forget"], "scratchpad": ["scratch", "scratchpad", "working note", "jot down", "notepad"], "ha": ["home assistant", "light", "thermostat", "turn on", "turn off", "switch", "sensor", "temperature in", "kitchen", "bedroom", "garage"], "aether": ["journal", "aether journal", "note entry", "log entry", "search journal", "ae_journal"], "aether_db": ["database", "query", "sql", "select", "db", "table", "schema", "maria", "run query"], "notifications":["notify", "push notification", "send email", "email", "talk message", "nextcloud"], "agents": ["spawn", "sub-agent", "delegate", "spawn agent", "agent status", "agent list", "cancel agent", "background agent", "aider", "code change", "edit code", "make a change to", "fix the code"], "notes": ["agent notes", "private notes", "my notes", "agent_notes"], "session": ["session", "history", "last time", "what did we", "earlier", "yesterday", "last week", "previously"], "ae_tasks": ["ae task", "kanban", "board", "ae_task"], "claude": ["claude allow", "claude directory"], } def classify_tool_categories(message: str) -> list[str]: """Return category names whose keywords appear in message (case-insensitive). Empty return means no tool category matched — route as pure chat with zero tool overhead. """ low = message.lower() return [cat for cat, kws in _KEYWORD_CATEGORY_MAP.items() if any(kw in low for kw in kws)] def narrow_tools_by_keywords( message: str, role_tools: list[str] | None, context_messages: list[dict] | None = None, ) -> list[str]: """Narrow the active tool list to categories relevant to this message. Also scans the last assistant message in context_messages — this catches follow-up patterns like "yes, please do that" where the tool intent was expressed by the assistant in the prior turn and the user is simply confirming. Returns [] if no keywords matched (zero tool overhead). Returns keyword-matched tools, intersected with role_tools if role_tools is set. """ scan_text = message if context_messages: for m in reversed(context_messages): if m.get("role") == "assistant": scan_text = scan_text + " " + (m.get("content") or "") break matched = classify_tool_categories(scan_text) if not matched: return [] seen: set[str] = set() dynamic: list[str] = [] for cat in matched: for t in CATEGORY_TOOL_MAP.get(cat, []): if t not in seen: seen.add(t) dynamic.append(t) if role_tools is not None: role_set = set(role_tools) dynamic = [t for t in dynamic if t in role_set] return dynamic