feat: tool schema optimization, keyword routing, aider_run coding agent
Tool schema optimization (PLAN__Tool_Schema_Optimization.md Phases 1-3): - model_registry.py: ROLE_DEFAULT_TOOLS — distill gets [], research/coder get narrow tool lists by default; applied in get_role_config() when user hasn't configured a custom list - openai_orchestrator.py: keyword routing via narrow_tools_by_keywords() — scans user message + last assistant turn; narrows active schemas to matched categories only (e.g. "weather" → 3 web tools instead of 69); zero tools sent for pure chat - openai_orchestrator.py: _get_cached_tools() — module-level schema cache keyed by (role, sorted_tool_list, risk_params); eliminates redundant schema rebuilds - openai_orchestrator.py: _TOOL_SCHEMA_OVERHEAD 3000 → 500 tokens (schemas now excluded from the per-call fixed estimate since they're cached separately) - tools/__init__.py: CATEGORY_TOOL_MAP + _KEYWORD_CATEGORY_MAP + classify_tool_categories() + narrow_tools_by_keywords() — the classifier logic lives here so both orchestrators can share it aider_run tool (cortex/tools/aider.py): - Invokes Aider as a subprocess with --message --yes-always --no-pretty --no-stream - Project aliases: cortex / aether_api / aether_frontend / aether_container - Auto-injects OpenRouter API key from Cortex model registry (no ~/.env needed) - background=True fires async + registers in agent_manager; notify=True sends push notification on completion - admin-only, confirm-required, TOOL_RISK=high - .gitignore: added .aider.chat.history.md / .aider.input.history / .aider.llm.history Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -87,7 +87,13 @@ from tools.git import (
|
||||
git_log as _git_log,
|
||||
git_diff as _git_diff,
|
||||
)
|
||||
from tools.agents import spawn_agent as _spawn_agent
|
||||
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,
|
||||
@@ -114,6 +120,7 @@ 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
|
||||
|
||||
@@ -140,7 +147,7 @@ TOOL_CATEGORIES: dict[str, list[str]] = {
|
||||
],
|
||||
"Aether Tasks": ["ae_task_list"],
|
||||
"Agent Notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"],
|
||||
"Agents": ["spawn_agent"],
|
||||
"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"],
|
||||
}
|
||||
@@ -207,6 +214,10 @@ _CALLABLES: dict[str, callable] = {
|
||||
"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,
|
||||
@@ -230,6 +241,10 @@ TOOL_ROLES: dict[str, str] = {
|
||||
"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",
|
||||
@@ -251,6 +266,8 @@ CONFIRM_REQUIRED: set[str] = {
|
||||
"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.
|
||||
@@ -348,8 +365,12 @@ TOOL_RISK: dict[str, str] = {
|
||||
"git_log": "low",
|
||||
"git_diff": "low",
|
||||
|
||||
# Agents — spawning a subprocess with broad permissions is high
|
||||
# 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",
|
||||
@@ -388,6 +409,7 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
|
||||
+ _mod_ae_tasks.DECLARATIONS
|
||||
+ _mod_agent_notes.DECLARATIONS
|
||||
+ _mod_agents.DECLARATIONS
|
||||
+ _mod_aider.DECLARATIONS
|
||||
+ _mod_homeassistant.DECLARATIONS
|
||||
+ _mod_ae_database.DECLARATIONS
|
||||
)
|
||||
@@ -554,3 +576,114 @@ def get_openai_tools_for_role(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user