Files
2026-04-04 19:13:58 -07:00

656 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
tools.py — AIPA Tool Registry
Provides a lightweight @tool() decorator, schema export, and tool execution
for agent tool-calling loops.
Agent-management tools allow Evelyn (and any tool-enabled agent) to read and
modify agent definitions without restarting the session.
"""
from __future__ import annotations
import inspect
import json
from pathlib import Path
from typing import Callable, Any
import config as _config
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
_REGISTRY: dict[str, dict] = {} # name → {fn, schema}
def tool(description: str, parameters: dict):
"""
Decorator that registers a callable as a named tool.
Parameters
----------
description : str
Plain-language description shown to the LLM.
parameters : dict
JSON Schema object for the tool's parameters (OpenAI function-calling
format — {"type": "object", "properties": {...}, "required": [...]}).
"""
def decorator(fn: Callable) -> Callable:
name = fn.__name__
_REGISTRY[name] = {
"fn": fn,
"schema": {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": parameters,
},
},
}
return fn
return decorator
def get_schemas(names: list[str]) -> list[dict]:
"""Return OpenAI-compatible tool schemas for the named tools."""
result = []
for name in names:
entry = _REGISTRY.get(name)
if entry is None:
raise ValueError(
f"Tool {name!r} is not registered. "
f"Available tools: {list(_REGISTRY)}"
)
result.append(entry["schema"])
return result
def call(name: str, arguments_json: str) -> str:
"""
Execute a registered tool by name with a JSON-encoded arguments string.
Returns a JSON-encoded result string (always a string for message history).
"""
entry = _REGISTRY.get(name)
if entry is None:
return json.dumps({"error": f"Unknown tool: {name!r}"})
try:
kwargs = json.loads(arguments_json) if arguments_json else {}
result = entry["fn"](**kwargs)
return json.dumps(result, ensure_ascii=False)
except Exception as exc:
return json.dumps({"error": str(exc)})
# ---------------------------------------------------------------------------
# YAML helpers (ruamel for comment-preserving round-trips)
# ---------------------------------------------------------------------------
def _load_agents_yaml() -> tuple[Any, Any]:
"""
Load agents.yaml using ruamel.yaml for comment preservation.
Returns (ruamel_instance, data_dict).
"""
from ruamel.yaml import YAML
ryaml = YAML()
ryaml.preserve_quotes = True
path = _config._AGENTS_CONFIG_PATH
with open(path, encoding="utf-8") as fh:
data = ryaml.load(fh)
return ryaml, data
def _save_agents_yaml(ryaml: Any, data: Any) -> None:
"""Write agents.yaml back with comment preservation."""
path = _config._AGENTS_CONFIG_PATH
with open(path, "w", encoding="utf-8") as fh:
ryaml.dump(data, fh)
# ---------------------------------------------------------------------------
# Agent-management tools
# ---------------------------------------------------------------------------
@tool(
description="List all agents currently defined in agents.yaml, including their title, provider, model, temperature, max_tokens, stateful flag, and tools list.",
parameters={
"type": "object",
"properties": {},
"required": [],
},
)
def list_agents() -> list[dict]:
"""Return all agent configs as a list of dicts."""
result = []
for name, cfg in _config.AGENT_CONFIGS.items():
result.append({
"name": name,
"title": _config.agent_title(name),
"color": cfg.get("color", ""),
"prompt_file": cfg.get("prompt_file", ""),
"provider": _config.agent_provider_name(name),
"provider_type": _config.agent_provider_type(name),
"model": _config.agent_model(name),
"temperature": cfg.get("temperature"),
"max_tokens": cfg.get("max_tokens"),
"stateful": cfg.get("stateful"),
"tools": cfg.get("tools", []),
})
return result
@tool(
description="Read the full configuration for a specific agent by name, including provider type, thinking mode, model, and tools.",
parameters={
"type": "object",
"properties": {
"agent_name": {
"type": "string",
"description": "The agent's short name (e.g. 'evelyn', 'atlas').",
}
},
"required": ["agent_name"],
},
)
def read_agent_config(agent_name: str) -> dict:
"""Return the full resolved config for one agent."""
name = agent_name.lower()
if name not in _config.AGENT_CONFIGS:
return {"error": f"No agent named {agent_name!r}. Known: {list(_config.AGENT_CONFIGS)}"}
cfg = _config.AGENT_CONFIGS[name]
provider_name = _config.agent_provider_name(name)
provider_def = _config._providers.get(provider_name, {})
return {
"name": name,
"title": _config.agent_title(name),
"color": cfg.get("color", ""),
"prompt_file": cfg.get("prompt_file"),
"provider": provider_name,
"provider_type": _config.agent_provider_type(name),
"provider_base_url": provider_def.get("base_url", ""),
"provider_default_model": provider_def.get("default_model", ""),
"model": _config.agent_model(name),
"temperature": cfg.get("temperature"),
"max_tokens": cfg.get("max_tokens"),
"stateful": cfg.get("stateful"),
"tools": cfg.get("tools", []),
}
@tool(
description=(
"Create or update an agent definition in agents.yaml. "
"Pass only the fields you want to set or change — omitted fields are left unchanged on update, "
"or get sensible defaults on create. "
"After calling this tool, the session must be reloaded (/reload) for changes to take effect."
),
parameters={
"type": "object",
"properties": {
"agent_name": {
"type": "string",
"description": "Short name for the agent (e.g. 'evelyn'). Used as the YAML key.",
},
"title": {
"type": "string",
"description": "Human-readable title (e.g. 'Director of Personnel & Systems').",
},
"color": {
"type": "string",
"description": "Rich terminal color name (e.g. 'white', 'cyan', 'green').",
},
"prompt_file": {
"type": "string",
"description": "Filename (not path) in agents/prompts/ (e.g. 'evelyn_director_of_personnel.md').",
},
"provider": {
"type": "string",
"description": "Provider name from agents.yaml providers section (e.g. 'vastblueai_thinking').",
},
"model": {
"type": "string",
"description": "Model name as the provider expects it. Empty string means use provider default.",
},
"temperature": {
"type": "number",
"description": "Sampling temperature 0.01.0. Use 0.6 for thinking, 0.7 for non-thinking.",
},
"max_tokens": {
"type": "integer",
"description": "Maximum response tokens.",
},
"stateful": {
"type": "boolean",
"description": "Whether the agent maintains conversation history across calls.",
},
"tools": {
"type": "array",
"items": {"type": "string"},
"description": "List of tool names from the tools registry that this agent can use.",
},
},
"required": ["agent_name"],
},
)
def upsert_agent_definition(
agent_name: str,
title: str | None = None,
color: str | None = None,
prompt_file: str | None = None,
provider: str | None = None,
model: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
stateful: bool | None = None,
tools: list[str] | None = None,
) -> dict:
"""Create or update an agent in agents.yaml."""
name = agent_name.lower()
# Validate provider if given
if provider is not None and provider != "none":
valid_providers = list(_config._providers.keys())
if provider not in valid_providers:
return {
"error": f"Unknown provider {provider!r}. Valid providers: {valid_providers}"
}
# Validate tools if given
if tools is not None:
unknown = [t for t in tools if t not in _REGISTRY]
if unknown:
return {
"error": f"Unknown tools: {unknown}. Registered tools: {list(_REGISTRY)}"
}
ryaml, data = _load_agents_yaml()
agents_section = data.get("agents", {})
creating = name not in agents_section
if creating:
# Supply required defaults for new agents
if not prompt_file:
return {"error": "prompt_file is required when creating a new agent."}
agents_section[name] = {
"title": title or name.capitalize(),
"color": color or "white",
"prompt_file": prompt_file,
"provider": provider or "",
"model": model if model is not None else "",
"temperature": temperature if temperature is not None else 0.7,
"max_tokens": max_tokens or 16384,
"stateful": stateful if stateful is not None else True,
}
if tools:
agents_section[name]["tools"] = tools
else:
entry = agents_section[name]
if title is not None: entry["title"] = title
if color is not None: entry["color"] = color
if prompt_file is not None: entry["prompt_file"] = prompt_file
if provider is not None: entry["provider"] = provider
if model is not None: entry["model"] = model
if temperature is not None: entry["temperature"] = temperature
if max_tokens is not None: entry["max_tokens"] = max_tokens
if stateful is not None: entry["stateful"] = stateful
if tools is not None: entry["tools"] = tools
_save_agents_yaml(ryaml, data)
action = "created" if creating else "updated"
return {"ok": True, "action": action, "agent": name, "note": "Run /reload to apply changes."}
@tool(
description="Read the system prompt file for an agent. Returns the raw Markdown content of the prompt file.",
parameters={
"type": "object",
"properties": {
"agent_name": {
"type": "string",
"description": "The agent's short name (e.g. 'evelyn').",
}
},
"required": ["agent_name"],
},
)
def read_agent_prompt(agent_name: str) -> dict:
"""Read and return the raw prompt file for an agent."""
name = agent_name.lower()
cfg = _config.AGENT_CONFIGS.get(name)
if cfg is None:
return {"error": f"No agent named {agent_name!r}."}
path = _config.PROMPTS_DIR / cfg["prompt_file"]
if not path.exists():
return {"error": f"Prompt file not found: {path}"}
return {"prompt_file": cfg["prompt_file"], "content": path.read_text(encoding="utf-8")}
@tool(
description=(
"Write or overwrite a prompt file in the agents/prompts/ directory. "
"Use the prompt_file name exactly (e.g. 'evelyn_director_of_personnel.md'). "
"The file will be created if it does not exist."
),
parameters={
"type": "object",
"properties": {
"prompt_file": {
"type": "string",
"description": "Filename (not path) in agents/prompts/. Must end with .md.",
},
"content": {
"type": "string",
"description": "Full Markdown content to write to the file.",
},
},
"required": ["prompt_file", "content"],
},
)
def write_agent_prompt(prompt_file: str, content: str) -> dict:
"""Write (or overwrite) a prompt file."""
if not prompt_file.endswith(".md"):
return {"error": "prompt_file must end with .md"}
# Disallow path traversal
if "/" in prompt_file or "\\" in prompt_file:
return {"error": "prompt_file must be a filename only, not a path."}
path = _config.PROMPTS_DIR / prompt_file
path.write_text(content, encoding="utf-8")
return {"ok": True, "wrote": str(path), "bytes": len(content.encode())}
@tool(
description="List all provider definitions from agents.yaml, showing type, base_url, default_model, and extra_body settings.",
parameters={
"type": "object",
"properties": {},
"required": [],
},
)
def list_providers() -> list[dict]:
"""Return all provider definitions."""
result = []
for name, pdef in _config._providers.items():
entry = {"name": name}
entry["type"] = pdef.get("type", "")
entry["base_url"] = pdef.get("base_url", "")
entry["default_model"] = pdef.get("default_model", "")
entry["extra_body"] = pdef.get("extra_body", {})
result.append(entry)
return result
@tool(
description="List all tools registered in the tool registry, with their names and descriptions.",
parameters={
"type": "object",
"properties": {},
"required": [],
},
)
def list_tools() -> list[dict]:
"""Return all registered tools with name and description."""
return [
{
"name": name,
"description": entry["schema"]["function"]["description"],
}
for name, entry in _REGISTRY.items()
]
# ---------------------------------------------------------------------------
# Task dispatch (used by Miranda — orchestrator drains this queue after her turn)
# ---------------------------------------------------------------------------
_pending_tasks: list[dict] = []
def get_pending_tasks() -> list[dict]:
"""
Drain and return all task specs queued by dispatch_task tool calls.
Call once per directive, after the orchestrator's tool loop completes.
"""
tasks = _pending_tasks.copy()
_pending_tasks.clear()
return tasks
@tool(
description=(
"Dispatch a task to a Lead agent. "
"Call this tool once per Lead you want to task — multiple calls dispatch multiple tasks in parallel. "
"Do NOT respond with TASK BRIEF text; use this tool instead. "
"Results will be returned to you when all Leads complete so you can synthesize."
),
parameters={
"type": "object",
"properties": {
"to": {
"type": "string",
"description": "Lead agent name (lowercase): atlas, cole, clio, iris, or evelyn.",
},
"directive": {
"type": "string",
"description": "What the Principal wants — the full task objective.",
},
"scope": {
"type": "string",
"description": "Exactly what this Lead is responsible for delivering.",
},
"constraints": {
"type": "string",
"description": "Deadlines, depth limits, format requirements, or assumptions to hold. Use 'NONE' if none.",
},
"dependencies": {
"type": "string",
"description": "Other tasks this depends on, or 'NONE'.",
},
"return_format": {
"type": "string",
"description": "Specific structure or emphasis you need back from this Lead beyond the standard STATUS / SUMMARY / FINDINGS / OPEN ITEMS format.",
},
},
"required": ["to", "directive", "scope"],
},
)
def dispatch_task(
to: str,
directive: str,
scope: str,
constraints: str = "NONE",
dependencies: str = "NONE",
return_format: str = "",
) -> dict:
"""Queue a task for dispatch to a Lead agent after Miranda's turn completes."""
lead = to.lower().strip()
if lead not in _config.LEAD_AGENT_NAMES:
return {
"error": f"Unknown lead {to!r}. Valid leads: {sorted(_config.LEAD_AGENT_NAMES)}"
}
_pending_tasks.append({
"to": lead,
"directive": directive,
"scope": scope,
"constraints": constraints,
"dependencies": dependencies,
"return_format": return_format,
})
return {
"queued": True,
"to": lead,
"note": "Task queued. You will receive lead outputs to synthesize when all tasks complete.",
}
# ---------------------------------------------------------------------------
# Knowledge base tools
# ---------------------------------------------------------------------------
@tool(
description=(
"Write or overwrite a Markdown document in the knowledge base. "
"The document is stored as a .md file on the filesystem. "
"Existing changelogs are preserved and a new entry is appended automatically. "
"Use Markdown with a top-level '# Title' heading as the first line."
),
parameters={
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "Filename for the document (e.g. 'project_goals.md'). Must end with .md.",
},
"content": {
"type": "string",
"description": "Full Markdown content to write (excluding changelog — that is managed automatically).",
},
"collection": {
"type": "string",
"description": "Collection (subdirectory) to store the document in. Defaults to the configured default collection.",
},
"agent": {
"type": "string",
"description": "Name of the agent writing this document — recorded in the changelog.",
},
"changelog_entry": {
"type": "string",
"description": "Short description of what changed, for the changelog (e.g. 'Added Q2 goals'). Auto-generated if omitted.",
},
},
"required": ["filename", "content"],
},
)
def kb_write(
filename: str,
content: str,
collection: str = "",
agent: str = "",
changelog_entry: str = "",
) -> dict:
"""Write or overwrite a KB Markdown document."""
import knowledge_base as _kb
return _kb.kb_write(
filename=filename,
content=content,
collection=collection,
agent=agent,
changelog_entry=changelog_entry,
)
@tool(
description=(
"Read a Markdown document from the knowledge base. "
"Returns the document body and its changelog history separately."
),
parameters={
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "Filename of the document to read (e.g. 'project_goals.md').",
},
"collection": {
"type": "string",
"description": "Collection containing the document. Defaults to the configured default collection.",
},
},
"required": ["filename"],
},
)
def kb_read(filename: str, collection: str = "") -> dict:
"""Read a KB document, returning body and changelog separately."""
import knowledge_base as _kb
return _kb.kb_read(filename=filename, collection=collection)
@tool(
description=(
"Search the knowledge base by semantic similarity. "
"Syncs the embedding index from the filesystem before querying. "
"Returns matching documents ordered by relevance (lower distance = more similar)."
),
parameters={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Natural-language search query.",
},
"n_results": {
"type": "integer",
"description": "Maximum number of results to return. Defaults to 5.",
},
"collection": {
"type": "string",
"description": "Collection to search. Defaults to the configured default collection.",
},
},
"required": ["query"],
},
)
def kb_search(query: str, n_results: int = 5, collection: str = "") -> list[dict]:
"""Semantic search in the knowledge base."""
import knowledge_base as _kb
return _kb.kb_search(query=query, n_results=n_results, collection=collection)
@tool(
description="List all Markdown documents in a knowledge base collection with their titles and modification times.",
parameters={
"type": "object",
"properties": {
"collection": {
"type": "string",
"description": "Collection to list. Defaults to the configured default collection.",
},
},
"required": [],
},
)
def kb_list(collection: str = "") -> list[dict]:
"""List all documents in a KB collection."""
import knowledge_base as _kb
return _kb.kb_list(collection=collection)
@tool(
description=(
"Delete a Markdown document from the knowledge base. "
"The file is removed from disk; the embedding index is cleaned up on next search."
),
parameters={
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "Filename of the document to delete (e.g. 'old_notes.md').",
},
"collection": {
"type": "string",
"description": "Collection containing the document. Defaults to the configured default collection.",
},
},
"required": ["filename"],
},
)
def kb_delete(filename: str, collection: str = "") -> dict:
"""Delete a KB document from the filesystem."""
import knowledge_base as _kb
return _kb.kb_delete(filename=filename, collection=collection)
@tool(
description="List all collections in the knowledge base with their document counts.",
parameters={
"type": "object",
"properties": {},
"required": [],
},
)
def kb_list_collections() -> list[dict]:
"""List all knowledge base collections."""
import knowledge_base as _kb
return _kb.kb_list_collections()