From 2166af01be3db7d83bd63e95e12e2e4632f9c1bd Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Fri, 3 Apr 2026 09:01:01 -0700 Subject: [PATCH] Tool calling added. --- config/agents.yaml | 8 + docs/ARCHITECTURE.md | 861 ++++++++++++++++++ .../__pycache__/config.cpython-311.pyc | Bin 18219 -> 20873 bytes .../__pycache__/orchestrator.cpython-311.pyc | Bin 40662 -> 47105 bytes .../__pycache__/tools.cpython-311.pyc | Bin 0 -> 15617 bytes orchestration/__pycache__/ui.cpython-311.pyc | Bin 42722 -> 44974 bytes orchestration/config.py | 42 + orchestration/orchestrator.py | 145 +++ orchestration/requirements.txt | 1 + orchestration/tools.py | 392 ++++++++ orchestration/ui.py | 43 + 11 files changed, 1492 insertions(+) create mode 100644 docs/ARCHITECTURE.md create mode 100644 orchestration/__pycache__/tools.cpython-311.pyc create mode 100644 orchestration/tools.py diff --git a/config/agents.yaml b/config/agents.yaml index fae232c..fe1dc97 100644 --- a/config/agents.yaml +++ b/config/agents.yaml @@ -172,6 +172,14 @@ agents: temperature: 0.7 # Qwen3 non-thinking recommendation max_tokens: 16384 stateful: true + tools: + - list_tools + - list_agents + - read_agent_config + - upsert_agent_definition + - read_agent_prompt + - write_agent_prompt + - list_providers atlas: title: Director of Research diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..2545d8d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,861 @@ +# AIPA Architecture Reference + +**AI Personal Agency** — a multi-agent orchestration system where a Principal +(human user) issues directives that are routed, executed, audited, and +synthesized by a named hierarchy of LLM agents. + +--- + +## Table of Contents + +1. [Repository Layout](#1-repository-layout) +2. [System Overview](#2-system-overview) +3. [Request Lifecycle](#3-request-lifecycle) +4. [Agent Hierarchy](#4-agent-hierarchy) +5. [Configuration System](#5-configuration-system) +6. [Provider System](#6-provider-system) +7. [Orchestration Modules](#7-orchestration-modules) +8. [Tool-Calling System](#8-tool-calling-system) +9. [Streaming Output](#9-streaming-output) +10. [Terminal UI](#10-terminal-ui) +11. [Memory and Persistence](#11-memory-and-persistence) +12. [Key Data Structures](#12-key-data-structures) +13. [Function Reference](#13-function-reference) +14. [Environment Variables](#14-environment-variables) +15. [Extension Guide](#15-extension-guide) + +--- + +## 1. Repository Layout + +``` +AIPA/ +├── config/ +│ ├── agents.yaml # Agent definitions, providers, roles +│ └── app.yaml # App identity, paths, logging, infrastructure +│ +├── agents/ +│ ├── prompts/ # System prompt Markdown files (one per named agent) +│ │ ├── miranda_chief_of_staff.md +│ │ ├── vera_auditor.md +│ │ ├── evelyn_director_of_personnel.md +│ │ ├── atlas_research_lead.md +│ │ ├── cole_operations_lead.md +│ │ ├── clio_analysis_lead.md +│ │ └── iris_interface_director.md +│ ├── registry/ +│ │ └── agent_registry.md # Living roster maintained by Evelyn +│ └── specs/ +│ └── evelyn_spec_iris_v1.md # Agent recruitment specifications +│ +├── orchestration/ # All runnable Python code lives here +│ ├── orchestrator.py # Entry point; session REPL; provider clients; pipeline +│ ├── config.py # Unified config loader (app.yaml + agents.yaml + .env) +│ ├── ui.py # Rich terminal UI (Iris-authored) +│ ├── tools.py # Tool registry and agent-management tools +│ ├── task_store.py # JSON task persistence per session +│ ├── requirements.txt # Python dependencies +│ └── .venv/ # Virtual environment +│ +├── docs/ +│ ├── standing_brief.md # Miranda's cross-session working memory (mutable) +│ ├── standing_brief_template.md # Blank template for /reset-brief +│ └── archive/ +│ └── standing_brief_archive.md +│ +├── data/ +│ └── tasks/ # session_.json files written by task_store.py +│ +├── logs/ # Runtime logs (if file logging enabled) +└── .env # API keys — never committed +``` + +--- + +## 2. System Overview + +AIPA is a **single-process, async Python** application. There is no network +server, message queue, or database in the default configuration — everything +runs as in-process Python objects connected by `asyncio`. + +``` +┌─────────────────────────────────────────────────────────┐ +│ Principal (human at terminal) │ +└────────────────────────┬────────────────────────────────┘ + │ directive (text) + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Session REPL (orchestrator.py: principal_session) │ +│ Slash-command dispatch → run_directive() │ +└────────────────────────┬────────────────────────────────┘ + │ + ┌──────────▼──────────┐ + │ Miranda (CoS) │ orchestrator role + │ routes / responds │ stateful, thinking mode + └──┬──────────────────┘ + │ TASK BRIEF blocks (if leads needed) + ┌────────┼────────┬──────────────┐ + ▼ ▼ ▼ ▼ + Atlas Cole Clio (other leads) + Research Ops Analysis dispatched in parallel + │ │ │ + └────────┴────────┘ + │ outputs collected + ▼ + Miranda synthesizes → deliverable + │ + ▼ + Vera audits (independent, stateless) + │ + ▼ + Principal sees deliverable + audit memo +``` + +--- + +## 3. Request Lifecycle + +`Session.run_directive(directive)` in `orchestrator.py` implements the full +pipeline. Steps, in order: + +| Step | What happens | Key call | +|------|-------------|----------| +| 1 | Miranda receives the directive and decides: respond directly or issue task briefs | `call_agent_async(orchestrator, cos_prompt, stream=True)` | +| 2 | Task briefs are parsed from Miranda's response | `parse_task_briefs(cos_response, task_id)` | +| 3 | If leads are needed: tasks are persisted then dispatched concurrently | `dispatch_all_leads(leads, tasks)` → `asyncio.gather` | +| 4 | Miranda synthesizes all lead outputs into a single deliverable | `call_agent_async(orchestrator, synthesis_prompt, stream=True)` | +| 5 | Vera audits the deliverable (if `session.auto_audit` is True) | `run_auditor(auditor, deliverable, task_id)` | +| 6 | Deliverable and audit memo are displayed; session log updated | `ui.print_deliverable` / `ui.print_audit_memo` | + +**Direct response path** (Step 2 finds no task briefs): Miranda's Step 1 +response *is* the deliverable. Steps 3 and 4 are skipped. + +**Task ID format:** `T-YYYYMMDD-NNN-A`, `T-YYYYMMDD-NNN-B`, … +The counter resets each session; the letter suffix enumerates tasks within +one directive. + +--- + +## 4. Agent Hierarchy + +### System roles + +Roles are logical names. The actual agent assigned to each role is set in +`config/agents.yaml` under `roles:` and is read at startup — no code change +needed to reassign a role. + +| Role key | Default agent | Description | +|----------|--------------|-------------| +| `orchestrator` | `miranda` | Primary interface to the Principal; routes all work and synthesizes outputs | +| `auditor` | `vera` | Stateless independent reviewer; reports only to the Principal | +| `recruiter` | `evelyn` | Agent generator; creates and maintains agent definitions | + +Code always references `config.ORCHESTRATOR_AGENT`, `config.AUDITOR_AGENT`, +`config.RECRUITER_AGENT` — never hardcoded names. + +### Named agents (leads) + +Every agent in `agents.yaml` that is not assigned to a system role is a +**lead agent** and is available for task dispatch. + +| Agent | Title | Color | Provider | Mode | +|-------|-------|-------|----------|------| +| Miranda | Chief of Staff | cyan | vastblueai_thinking | thinking (0.6) | +| Vera | Auditor | yellow | vastblueai_thinking | thinking (0.6) | +| Evelyn | Director of Personnel & Systems | white | vastblueai | non-thinking (0.7) | +| Atlas | Director of Research | green | vastblueai_thinking | thinking (0.6) | +| Cole | Director of Operations | blue | vastblueai | non-thinking (0.7) | +| Clio | Director of Analysis | magenta | vastblueai_thinking | thinking (0.6) | +| Iris | Director of Interface & Experience | bright_cyan | vastblueai | non-thinking (0.7) | + +### Worker agents (runtime-generated) + +Individual worker agents (RSCH-###, OPS-###, ANLY-###, etc.) are generated +at runtime inside Lead prompts. They are not defined in `agents.yaml` and +have no static prompt files — their prompts are composed by their Lead as +part of the task execution. + +### Stateful vs stateless + +`stateful: true` — the agent's `history` list grows across calls within a +session. Miranda and all leads are stateful. + +`stateful: false` — history is discarded between calls. Only the last user +message and the immediately preceding assistant reply are sent. Vera is +stateless by design (each audit is independent). + +`AgentState.messages_for_call()` enforces this: returns `self.history` if +stateful, `self.history[-2:]` if not. + +--- + +## 5. Configuration System + +### Loading order + +`orchestration/config.py` runs at import time: + +1. Loads `.env` into `os.environ` (via `python-dotenv`) +2. Reads `config/app.yaml` → application identity, paths, logging, session defaults +3. Reads `config/agents.yaml` → providers, agent definitions, roles +4. Derives all module-level constants +5. Environment variables with `AIPA_` prefix override any value from the YAML files + +### `config/app.yaml` — key sections + +| Section | Purpose | +|---------|---------| +| `app` | Name, version, environment, log level | +| `paths` | All file paths (prompts dir, standing brief, task dir, etc.) | +| `logging` | Format (rich/plain/json), file logging settings | +| `session` | `log_retention`, `lead_timeout`, `auto_audit`, `debug_output` | +| `memory` | Standing brief toggle; future vector/DB store config | +| `infrastructure` | API server, cache, queue placeholders (all disabled by default) | + +Path resolution: relative paths are resolved against `AIPA_ROOT` if set, +otherwise against the repository root. Absolute paths are used as-is. + +### `config/agents.yaml` — key sections + +**`roles`** — maps logical role names to agent names: +```yaml +roles: + orchestrator: miranda + auditor: vera + recruiter: evelyn +``` + +**`default_provider`** — fallback provider for any agent without an explicit +`provider:` field. + +**`providers`** — named provider definitions: +```yaml +providers: + vastblueai: + type: openai_compatible + base_url: http://10.250.50.54:9292/v1 + api_key: local + default_model: "qwen3.5-35-a3b" + extra_body: + enable_thinking: false +``` +Provider types: `anthropic`, `openai`, `openai_compatible`, `ollama`. + +**`agents`** — named agent entries: +```yaml +agents: + evelyn: + title: Director of Personnel & Systems + color: white + prompt_file: evelyn_director_of_personnel.md + provider: vastblueai + model: "" # empty = use provider default_model + temperature: 0.7 + max_tokens: 16384 + stateful: true + tools: # optional — enables tool-calling loop + - list_agents + - upsert_agent_definition +``` + +### Reloading config at runtime + +`config.reload_agents_config()` re-reads `agents.yaml` from disk and +refreshes all derived module-level variables (`AGENT_CONFIGS`, +`LEAD_AGENT_NAMES`, `ORCHESTRATOR_AGENT`, etc.). The `/reload` slash command +calls this then rebuilds all session agents. + +### Module-level constants (config.py) + +| Constant | Type | Description | +|----------|------|-------------| +| `APP_NAME` | str | Application name | +| `APP_VERSION` | str | Version string | +| `APP_ENV` | str | `development` / `production` / `test` | +| `PROMPTS_DIR` | Path | Absolute path to `agents/prompts/` | +| `REGISTRY_PATH` | Path | Absolute path to `agents/registry/agent_registry.md` | +| `STANDING_BRIEF_PATH` | Path | Absolute path to the standing brief | +| `BRIEF_TEMPLATE_PATH` | Path | Absolute path to the blank template | +| `ARCHIVE_DIR` | Path | Absolute path to `docs/archive/` | +| `ARCHIVE_PATH` | Path | `ARCHIVE_DIR / "standing_brief_archive.md"` | +| `LOGS_DIR` | Path | Absolute path to `logs/` | +| `DATA_DIR` | Path | Absolute path to `data/` | +| `TASKS_DIR` | Path | Absolute path to `data/tasks/` | +| `SESSION_LOG_RETENTION` | int | Max session log entries in standing brief | +| `LEAD_TIMEOUT_SECONDS` | int | Per-lead API call timeout | +| `VERA_AUTO_AUDIT` | bool | Default auto-audit setting | +| `DEBUG_PRINT_AGENT_OUTPUTS` | bool | Print raw outputs when True | +| `ORCHESTRATOR_AGENT` | str | Agent name holding the orchestrator role | +| `AUDITOR_AGENT` | str | Agent name holding the auditor role | +| `RECRUITER_AGENT` | str | Agent name holding the recruiter role | +| `AGENT_CONFIGS` | dict | `{name: {prompt_file, temperature, max_tokens, stateful, tools}}` | +| `LEAD_AGENT_NAMES` | set | All agents not in a system role | +| `ACTIVE_PROVIDER` | str | `default_provider` value from agents.yaml | + +### Config helper functions + +| Function | Returns | Description | +|----------|---------|-------------| +| `agent_provider_name(agent)` | str | Named provider for an agent (falls back to default) | +| `agent_provider_type(agent)` | str | Provider type string | +| `agent_api_key(agent)` | str | API key from env; warns if missing | +| `agent_base_url(agent)` | str\|None | Base URL for the agent's provider | +| `agent_extra_body(agent)` | dict | Extra request body fields (e.g. `enable_thinking`) | +| `agent_model(agent)` | str | Model name; falls back to provider `default_model` | +| `agent_title(agent)` | str | Human-readable title from agents.yaml | +| `agent_color(agent)` | str | Rich color name from agents.yaml | +| `agent_tools(agent)` | list[str] | Tool names assigned to the agent | +| `reload_agents_config()` | None | Re-reads agents.yaml, refreshes all derived state | +| `ensure_runtime_dirs()` | None | Creates logs/, data/, archive/, tasks/ if missing | + +--- + +## 6. Provider System + +### Class hierarchy + +``` +ProviderClient (base — raises NotImplementedError if called) +└── OpenAIClient (openai + openai_compatible providers) + (AnthropicClient) (commented out — uncomment to enable) + (OllamaClient) (commented out — uncomment to enable) +``` + +`build_client(agent_name)` is the factory. It reads the agent's provider type +from config and constructs the appropriate client. + +### OpenAIClient + +Wraps `AsyncOpenAI` from the `openai` package. Covers all OpenAI-compatible +endpoints (OpenAI, Groq, Together, Mistral, LM Studio, llama.cpp, Ollama /v1, +the local `vastblueai` server). + +Key behaviour: +- `extra_body` is forwarded to every API call. For `vastblueai` this carries + `{"enable_thinking": true|false}` to control Qwen3 thinking mode. +- `call_async_streaming()` uses `stream=True` and yields + `('thinking', text)` chunks when `delta.reasoning_content` is present (Qwen3 / + DeepSeek R1 style) and `('content', text)` chunks for regular output. +- `call_async_with_tools()` makes a non-streaming call with OpenAI function-call + tool schemas and returns a `_ToolMessage`. + +### Adding a new provider + +1. Add a provider definition to `config/agents.yaml` under `providers:`. +2. If the endpoint is OpenAI-compatible, no code change is needed — just set + `type: openai_compatible` and `base_url`. +3. For a native API (Anthropic, Ollama native): uncomment or write a new + `ProviderClient` subclass in `orchestrator.py`, then add a branch to + `build_client()`. + +--- + +## 7. Orchestration Modules + +### orchestrator.py + +Entry point and pipeline engine. Run directly: + +```bash +cd orchestration +python orchestrator.py # interactive Principal session +python orchestrator.py --dry-run # trace routing without API calls +python orchestrator.py --agent evelyn # direct session with one agent +``` + +**Key functions:** + +| Function | Description | +|----------|-------------| +| `build_client(agent_name)` | Factory: returns the correct ProviderClient | +| `build_agent(name)` | Constructs AgentState from config + client | +| `load_system_prompt(prompt_file)` | Reads and extracts the system prompt section from a .md file | +| `load_standing_brief()` | Reads the standing brief (falls back to template) | +| `save_standing_brief(content)` | Writes the standing brief to disk | +| `call_agent_async(agent, message, ...)` | Core caller: routes to tool loop or streaming | +| `_run_tool_loop(agent, ...)` | Agentic tool-calling loop for tool-enabled agents | +| `parse_task_briefs(response, prefix)` | Extracts TASK BRIEF blocks from orchestrator response | +| `dispatch_to_lead(lead, task, ...)` | Calls one lead with one task; updates task_store | +| `dispatch_all_leads(leads, tasks, ...)` | Concurrently dispatches all leads via asyncio.gather | +| `run_auditor(auditor, deliverable, ...)` | Calls Vera with a deliverable; returns audit memo | +| `update_standing_brief(orchestrator, summary)` | Calls Miranda to produce an updated brief | +| `principal_session(dry_run)` | Main REPL loop | +| `direct_agent_session(agent_name, ...)` | One-agent REPL (--agent flag) | + +**Slash commands** are registered with `@command(["/name"], "description")`. +Each handler is `async def cmd_*(session, args) -> bool` where `False` exits +the REPL. Handlers are looked up at runtime by `_dispatch_command(name)`. + +Available commands: + +| Command | Description | +|---------|-------------| +| `/help` | Show command list | +| `/status` | Session stats, settings, and log | +| `/brief` | Show current standing brief | +| `/reset-brief` | Replace standing brief with blank template | +| `/agents` | Show agent roster with provider/model details | +| `/history` | Show session directive log | +| `/thinking` | Display stored thinking output from last response | +| `/audit` | Re-run Vera's audit on the last deliverable | +| `/debug` | Toggle raw agent output | +| `/tasks` | Show dispatched tasks and their status | +| `/autoaudit` | Toggle automatic Vera audit | +| `/clear` | Clear terminal | +| `/reload` | Reload agents.yaml and rebuild session agent roster | +| `/quit`, `/exit`, `/q` | Close session and update standing brief | + +**Prompt extraction:** `load_system_prompt()` searches for a +`## System Prompt` section in the .md file and returns everything up to +`## Access Configuration` or end-of-file. Falls back to stripping header +metadata if no section is found. + +### config.py + +See [Section 5](#5-configuration-system). + +### ui.py + +See [Section 10](#10-terminal-ui). + +### tools.py + +See [Section 8](#8-tool-calling-system). + +### task_store.py + +Writes one JSON file per session under `data/tasks/session_.json`. + +| Function | Description | +|----------|-------------| +| `record_tasks(tasks, session_id, directive)` | Create initial records after parse_task_briefs | +| `update_task(task, session_id)` | Update status/output/error after any state change | +| `load_session(session_id)` | Load the full session record from disk | +| `list_sessions()` | List all sessions with summary metadata, newest first | + +Task record fields: `task_id`, `directive`, `assigned_to`, `brief`, `status` +(`pending`|`in_progress`|`complete`|`error`), `output`, `error`, +`created_at`, `updated_at`. + +--- + +## 8. Tool-Calling System + +### Architecture + +Tool-enabled agents run through `_run_tool_loop()` instead of a single +streaming call. The loop: + +1. Calls the provider with tool schemas attached (`call_async_with_tools`) +2. If the response contains `tool_calls`: executes each tool, appends results + to history, displays the invocation via `ui.print_tool_call()`, loops +3. When the response has no `tool_calls`: streams the final content response + normally and exits + +This implements the standard OpenAI function-calling agentic pattern. + +### tools.py + +**Registry:** `_REGISTRY: dict[str, dict]` — maps tool name to `{fn, schema}`. + +**Registering a tool:** +```python +@tool( + description="One-sentence description shown to the LLM.", + parameters={ + "type": "object", + "properties": { + "my_param": {"type": "string", "description": "..."}, + }, + "required": ["my_param"], + }, +) +def my_tool(my_param: str) -> dict: + return {"result": my_param.upper()} +``` + +**Public API:** + +| Function | Description | +|----------|-------------| +| `get_schemas(names)` | Returns OpenAI-format tool schema list for named tools | +| `call(name, arguments_json)` | Execute a tool by name; returns JSON string | + +**Built-in agent-management tools:** + +| Tool name | Description | +|-----------|-------------| +| `list_agents` | All agents with config summary | +| `read_agent_config` | Full config for one agent by name | +| `upsert_agent_definition` | Create or update an agent in agents.yaml | +| `read_agent_prompt` | Read a prompt .md file by agent name | +| `write_agent_prompt` | Write/overwrite a prompt .md file | +| `list_providers` | All provider definitions | +| `list_tools` | All registered tools with descriptions | + +`upsert_agent_definition` uses `ruamel.yaml` for comment-preserving +round-trips. Changes take effect after `/reload`. + +### Assigning tools to an agent + +Add a `tools:` list to the agent's entry in `agents.yaml`: + +```yaml +agents: + evelyn: + ... + tools: + - list_agents + - upsert_agent_definition + - read_agent_prompt + - write_agent_prompt +``` + +Tool names must match registered function names in `tools.py`. +`config.agent_tools(agent)` returns the list; `config.AGENT_CONFIGS[name]["tools"]` +holds it directly. + +--- + +## 9. Streaming Output + +All agent calls default to `stream=True`. The flow: + +1. `call_agent_async()` detects `agent.tools` is empty → calls + `ui.stream_agent_output(agent_name, client.call_async_streaming(...), mode=...)` +2. `call_async_streaming()` on `OpenAIClient` connects with `stream=True` and + yields `('thinking', text)` and `('content', text)` tuples as chunks arrive. + Thinking tokens come from `delta.reasoning_content` (Qwen3 / DeepSeek R1). +3. `stream_agent_output()` in `ui.py` consumes the async generator and renders + output live using Rich's `Live` display. + +**Stream modes** (passed as `stream_mode` to `call_agent_async`): + +| Mode | Used for | Behaviour | +|------|----------|-----------| +| `"deliverable"` | Miranda's routing/synthesis responses | Full panel with thinking + content | +| `"audit"` | Vera's audit memos | Yellow thinking identity; verdict-aware border colour | +| `"background"` | Standing brief update | Compact transient spinner; no panel | + +`_last_stream_rendered` is set to `True` after `stream_agent_output` completes, +causing the subsequent `print_deliverable()` / `print_audit_memo()` to become +no-ops (avoiding double rendering). + +`ui.get_last_thinking()` returns the full thinking text from the most recent +response, accessible via `/thinking`. + +--- + +## 10. Terminal UI + +`orchestration/ui.py` is authored and maintained by Iris. + +### Visual language + +| Agent / concept | Color | Notes | +|-----------------|-------|-------| +| Miranda | cyan | Primary orchestrator | +| Vera | yellow | Auditor, independent track | +| Atlas | green | Research | +| Cole | blue | Operations | +| Clio | magenta | Analysis | +| Evelyn | white | Personnel & systems | +| Iris | bright_cyan | Interface | +| System messages | dim white | Infrastructure notes | +| Errors | bold red | Failures and blockers | +| Warnings | bold yellow | Flags and cautions | +| Task IDs | dim cyan | Always dim, never distracting | + +The Rich `Theme` is built dynamically from `agents.yaml` color values so +that adding or recoloring an agent in config is reflected automatically. + +### Key UI functions + +| Function | Description | +|----------|-------------| +| `stream_agent_output(agent_name, gen, mode)` | Live streaming render with thinking panel | +| `print_agent_panel(agent_name, content)` | Static Markdown panel for an agent | +| `print_deliverable(content, task_id)` | Orchestrator's synthesized output | +| `print_audit_memo(content, task_id)` | Vera's memo with verdict-based border | +| `print_tool_call(agent, tool, args_json, result_json)` | Inline tool invocation display | +| `print_thinking_expansion(text)` | Full thinking text in a dim panel | +| `lead_dispatch_progress(tasks)` | Context manager: live per-task progress bars | +| `print_session_header()` | Opening banner | +| `print_session_footer(count)` | Closing rule | +| `print_status(...)` | `/status` command output | +| `print_agents(configs, provider)` | `/agents` roster table | +| `get_directive()` | Styled `Principal ›` input prompt | +| `get_last_thinking()` | Returns stored thinking from last response | + +### Thinking panel behaviour + +During the thinking phase a dim panel shows a rolling 300-character window +of the thinking text with an overflow indicator (`+N chars earlier`) when +it exceeds the window. When the first content token arrives the panel +collapses to a single summary line: `▸ Thinking [N chars · L lines]`. + +### Audit verdict border + +`_print_audit_memo_panel()` (and the audit path in `stream_agent_output`) +scan the content for a `VERDICT:` line and map its value to a border color: +- `PASS` → green +- `PASS WITH NOTES` → warning (bold yellow) +- `FLAG` → flag (bold yellow) +- `REJECT` → red +- (none found) → agent color (yellow) + +--- + +## 11. Memory and Persistence + +### Standing Brief + +The standing brief (`docs/standing_brief.md`) is Miranda's cross-session +working memory. At session startup it is appended to Miranda's system prompt: + +```python +orchestrator.system_prompt += "\n\n---\n\n## Standing Brief (current)\n\n" + brief +``` + +When the session closes (`/quit`), Miranda is called to produce a fully +updated brief incorporating the session summary, and the result is written +back to disk. Old session log entries beyond `SESSION_LOG_RETENTION` are +archived to `docs/archive/standing_brief_archive.md`. + +### Task Store + +`task_store.py` writes JSON files to `data/tasks/`. Each directive that +dispatches leads produces one session file. The file is updated after every +task state transition (`pending` → `in_progress` → `complete`/`error`). + +This allows an interrupted session to be reconstructed and provides an audit +trail of all work dispatched. + +### Agent Memory Store + +Placeholder configuration exists in `app.yaml` under `memory.store` for +future vector/DB-backed agent memory (SQLite, PostgreSQL, Chroma, Pinecone). +Currently disabled (`enabled: false`); not implemented in code. + +--- + +## 12. Key Data Structures + +### AgentState (orchestrator.py) + +```python +@dataclass +class AgentState: + name: str # agent key (e.g. "miranda") + system_prompt: str # loaded from prompt file + optional brief injection + model: str # resolved model name + temperature: float + max_tokens: int + stateful: bool + client: ProviderClient # provider-specific LLM client + history: list[dict] # message history [{"role":..., "content":...}] + tools: list[str] # tool names from agents.yaml +``` + +`messages_for_call()` returns `history` (stateful) or `history[-2:]` (stateless). + +### Task (orchestrator.py) + +```python +@dataclass +class Task: + task_id: str # e.g. "T-20260402-001-A" + directive: str # the lead's portion of the orchestrator prompt + assigned_to: str # agent name + brief: str # full TASK BRIEF text sent to the lead + status: str # pending | in_progress | complete | error + output: str # lead's response text + error: str # exception message on failure +``` + +### _ToolMessage (orchestrator.py) + +```python +@dataclass +class _ToolMessage: + content: str # text content from the LLM + tool_calls: list | None # openai ToolCall objects, or None +``` + +### Tool history messages + +When a tool is called, two messages are appended to `agent.history`: + +```python +# Assistant message with tool call +{"role": "assistant", "content": "", "tool_calls": [ + {"id": "call_abc", "type": "function", + "function": {"name": "list_agents", "arguments": "{}"}} +]} + +# Tool result +{"role": "tool", "tool_call_id": "call_abc", "content": "[{...}]"} +``` + +--- + +## 13. Function Reference + +### orchestrator.py — full index + +| Function / Class | Location | Purpose | +|-----------------|----------|---------| +| `ProviderClient` | class | Abstract base client | +| `OpenAIClient` | class | OpenAI / openai_compatible provider | +| `build_client(agent_name)` | fn | Provider client factory | +| `AgentState` | dataclass | Runtime agent state | +| `Task` | dataclass | Task record | +| `_ToolMessage` | dataclass | Tool-call response | +| `load_system_prompt(prompt_file)` | fn | Reads .md, extracts system prompt section | +| `load_standing_brief()` | fn | Reads brief or falls back to template | +| `save_standing_brief(content)` | fn | Writes brief to disk | +| `build_agent(name)` | fn | Constructs AgentState from config | +| `new_task_id()` | fn | Generates next task ID | +| `call_agent_async(agent, msg, ...)` | async fn | Core call; routes to tool loop or stream | +| `_run_tool_loop(agent, ...)` | async fn | Agentic tool-calling loop | +| `parse_task_briefs(response, prefix)` | fn | Regex-extracts TASK BRIEF blocks | +| `dispatch_to_lead(lead, task, ...)` | async fn | Calls one lead; updates progress and store | +| `dispatch_all_leads(leads, tasks, ...)` | async fn | Concurrent lead dispatch | +| `run_auditor(auditor, deliverable, ...)` | async fn | Vera audit call | +| `update_standing_brief(orch, summary)` | async fn | Miranda brief update call | +| `Session` | class | Full session state and pipeline | +| `Session.run_directive(directive)` | async method | Full 5-step pipeline | +| `Session.close()` | async method | Brief update + footer | +| `command(names, desc)` | decorator | Registers slash command handler | +| `principal_session(dry_run)` | async fn | Main REPL | +| `direct_agent_session(name, ...)` | async fn | Single-agent REPL | +| `main()` | fn | CLI entry point | + +### tools.py — full index + +| Function / decorator | Purpose | +|---------------------|---------| +| `@tool(description, parameters)` | Register a function as a tool | +| `get_schemas(names)` | Return OpenAI-format schemas for named tools | +| `call(name, arguments_json)` | Execute tool by name; return JSON string | +| `_load_agents_yaml()` | ruamel.yaml load for comment-preserving round-trip | +| `_save_agents_yaml(ryaml, data)` | Write back agents.yaml with comments intact | +| `list_agents()` | Tool: all agent configs | +| `read_agent_config(agent_name)` | Tool: one agent's full config | +| `upsert_agent_definition(agent_name, ...)` | Tool: create or update agent in YAML | +| `read_agent_prompt(agent_name)` | Tool: read prompt .md by agent name | +| `write_agent_prompt(prompt_file, content)` | Tool: write/overwrite prompt .md | +| `list_providers()` | Tool: all provider definitions | +| `list_tools()` | Tool: all registered tools | + +--- + +## 14. Environment Variables + +All variables use the `AIPA_` prefix. Set them in `.env` (loaded automatically) +or as shell environment variables (override `.env`). + +### Required (if using cloud providers) + +| Variable | Provider | Description | +|----------|----------|-------------| +| `ANTHROPIC_API_KEY` | anthropic | Anthropic API key | +| `OPENAI_API_KEY` | openai | OpenAI API key | +| `GROQ_API_KEY` | groq | Groq API key | +| `TOGETHER_API_KEY` | together | Together AI key | +| `MISTRAL_API_KEY` | mistral | Mistral API key | +| `ANYSCALE_API_KEY` | anyscale | Anyscale key | + +Local providers (`vastblueai`, `lmstudio`, `llamacpp`, `ollama`) use +`api_key: local` in agents.yaml and need no env variable. + +### Application overrides + +| Variable | Default | Description | +|----------|---------|-------------| +| `AIPA_ROOT` | (repo root) | Relocate all mutable data paths | +| `AIPA_APP_NAME` | AIPA | Application display name | +| `AIPA_ENV` | development | Environment name | +| `AIPA_LOG_LEVEL` | INFO | Log level | +| `AIPA_LOG_FORMAT` | rich | Terminal format (rich/plain/json) | +| `AIPA_LOG_FILE_ENABLED` | false | Enable file logging | +| `AIPA_LOG_FILE_PATH` | logs/aipa.log | Log file path | +| `AIPA_PROMPTS_DIR` | agents/prompts | Prompts directory | +| `AIPA_STANDING_BRIEF_PATH` | docs/standing_brief.md | Standing brief path | +| `AIPA_ARCHIVE_DIR` | docs/archive | Archive directory | +| `AIPA_TASKS_DIR` | data/tasks | Task JSON directory | +| `AIPA_SESSION_LOG_RETENTION` | 10 | Sessions kept in brief before archiving | +| `AIPA_LEAD_TIMEOUT` | 120 | Lead API call timeout (seconds) | +| `AIPA_AUTO_AUDIT` | true | Enable Vera auto-audit | +| `AIPA_DEBUG` | false | Print raw agent outputs | +| `AIPA_MEMORY_BRIEF_ENABLED` | true | Enable standing brief | +| `AIPA_APP_CONFIG` | config/app.yaml | Override app.yaml path | +| `AIPA_AGENTS_CONFIG` | config/agents.yaml | Override agents.yaml path | + +--- + +## 15. Extension Guide + +### Add a new named agent + +1. Write a system prompt file in `agents/prompts/_.md`. + Structure: metadata block, then `## System Prompt` section. +2. Add the agent to `config/agents.yaml` under `agents:` with all required + fields (`title`, `color`, `prompt_file`, `provider`, `model`, `temperature`, + `max_tokens`, `stateful`). +3. The agent becomes available for lead dispatch immediately on next startup + (or after `/reload`). No code changes needed. + +Alternatively, have Evelyn create the agent during a session — she has +`upsert_agent_definition` and `write_agent_prompt` tools. + +### Add a new provider + +1. Add an entry to `config/agents.yaml` under `providers:`. +2. For OpenAI-compatible endpoints: set `type: openai_compatible` and + `base_url`. No code change needed. +3. For native APIs: implement a `ProviderClient` subclass in `orchestrator.py` + and add a branch to `build_client()`. +4. Add API key env variable to `.env` and reference it via `api_key_env:`. + +### Add a new tool + +1. Write the function in `orchestration/tools.py` (or a new module that + imports and calls `@tool()`). +2. Decorate it with `@tool(description=..., parameters={...})`. +3. Add the tool name to the `tools:` list of any agent that should have + access in `agents.yaml`. +4. Run `/reload` or restart the session. + +Tools receive `**kwargs` parsed from the LLM's JSON arguments. Always return +a JSON-serialisable value (dict, list, str, etc.). The `call()` dispatcher +JSON-encodes the return value automatically. + +### Reassign a system role + +Edit the `roles:` section in `config/agents.yaml`: +```yaml +roles: + orchestrator: new_agent_name +``` +Restart the session (or `/reload`). No code changes needed. + +### Extend the tool-calling loop + +`_run_tool_loop()` in `orchestrator.py` is the central loop. To add +parallel tool execution, streaming tool results, or loop limits, edit +that function directly. + +### Add a slash command + +```python +@command(["/mycommand", "/mc"], "Brief description shown in /help") +async def cmd_mycommand(session: Session, args: str) -> bool: + # args is everything after the command name + # Return True to continue the REPL, False to exit + ui.print_system(f"You typed: {args}") + return True +``` + +Commands self-register at import time via `COMMANDS` list; no other +wiring needed. diff --git a/orchestration/__pycache__/config.cpython-311.pyc b/orchestration/__pycache__/config.cpython-311.pyc index 060aaa209f0f33653786e9a4aa2eabd019c9253c..be344eaf2bce2016acc6c17d2363b916a4c01101 100644 GIT binary patch delta 3943 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 CF5JHW diff --git a/orchestration/__pycache__/orchestrator.cpython-311.pyc b/orchestration/__pycache__/orchestrator.cpython-311.pyc index e26df96d6870ac043f5a37d37b2037d487cbf3b4..7b7d3722c4a08328b8560931d85a7ca5b439a4fc 100644 GIT binary patch delta 12595 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 CiAlZy delta 7028 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 SfIZQH*WNKjkN(v$`TqeRprn)l diff --git a/orchestration/__pycache__/tools.cpython-311.pyc b/orchestration/__pycache__/tools.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6208e65a1400fcb265631e13f4b4091c5f94ff03 GIT binary patch literal 15617 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 LzTZovHQ@JuD65&G delta 1366 zcmZWnZA=_x5Z-xj+56z`dc6bgj)UGE^!P6H_@;ccKxrvgS_)|OEn>l0BFS!yrKVa; zE+&^4qo%^Pi5l#`0$O7T@4u!osm6~+iME=k71Kl;|1_~xqlqO(=hkbHcC+v1nP+C6 znR#d5yUu=GVeSQ&%OTNoYUqo}qbFzF0r+~Rv^trkDY}(gbsM*Vgn)`>*B#tpVyl*| zJGs-uHqE8Gx!c5cZIkZd9uqsX&AOL+bszWXIXp+t<+*wu&(ro@x2j7-hyLWDHPdIj-A5vMVq@CGkoOsa2$1K+HBXnqeW~YTQDZ z48w$LGB`qbdj>}d$CFq@C5p`wTZyQ}$FPK$QnkvIQm`zQ#p|$KttY7pifB+-#;Q!o z^VIVB^diiMH)56AWGZeWMFp!Vz8OdV&ti>QHetib8A_`s zXDhLH85tXhY1_aw64SncX(Fa$1Jg{*PGUByw-CDvcbIq6nxqapX`;52bl)sp#I`@c zb`#t20K3~Pv6BW!FH6B)q}pSb@F;c>*R7V_^Y1qAb5FfmI`_ek1==~JkkNv6z4^YX26dzmn;{dgE zh|b_RZNrH}6np@OX?G8iq>K-feenJy5Y7C0$f{&`Sdq)Dc#jZ3pWtZzYCcvEdt6+w~+v4Rg0=#3~j(h_P;?roC49gT!Bx-a@BRF{G8T3ZLGzM#f6$y27ib@)@U3wXp@~K;#Tvs44TA~ ztqlzN#M$;$3w1l)>1S|47~MZGNQ$TTj5F$MG~pLFdyg?VD@GHu4Bi#L_EXYDQ9AIk z3?GSKhGQOR7CGuo1xA)Nn(&tJJvAr8d9gJ00l<=&dZv%TCGq}=HjDBVqxLS-*dY;` zUbDds;ZI$#TfG*ddzb;!`Qo=%FU!y-F3z4&ARz|dxXR$Th|T!`o-NKvMD}rI; z?{g7RHa`N&H5n(LIBvYOFl$5>gO2AxlIWGDN{sHscJa#MPl{V&&~ZEcIaW)ae*rHQ BdrbfU diff --git a/orchestration/config.py b/orchestration/config.py index 2b0e484..f8f046d 100644 --- a/orchestration/config.py +++ b/orchestration/config.py @@ -296,10 +296,52 @@ AGENT_CONFIGS: dict[str, dict] = { "temperature": adef["temperature"], "max_tokens": adef["max_tokens"], "stateful": adef["stateful"], + "tools": list(adef.get("tools") or []), } for name, adef in _agents.items() } + +def agent_tools(agent: str) -> list[str]: + """List of tool names assigned to an agent (empty list if none).""" + return AGENT_CONFIGS.get(agent, {}).get("tools", []) + + +def reload_agents_config() -> None: + """ + Re-read agents.yaml and refresh all derived module-level values. + Call after upsert_agent_definition() to pick up changes in the running session. + """ + global _agents_cfg, _providers, _agents + global AGENT_CONFIGS, LEAD_AGENT_NAMES, ACTIVE_PROVIDER + global ORCHESTRATOR_AGENT, AUDITOR_AGENT, RECRUITER_AGENT + global _roles, _SYSTEM_AGENTS + + with open(_AGENTS_CONFIG_PATH, encoding="utf-8") as fh: + _agents_cfg = yaml.safe_load(fh) + + _providers = _agents_cfg.get("providers", {}) + _agents = _agents_cfg.get("agents", {}) + + AGENT_CONFIGS = { + name: { + "prompt_file": adef["prompt_file"], + "temperature": adef["temperature"], + "max_tokens": adef["max_tokens"], + "stateful": adef["stateful"], + "tools": list(adef.get("tools") or []), + } + for name, adef in _agents.items() + } + + _roles = _agents_cfg.get("roles", {}) + ORCHESTRATOR_AGENT = _roles.get("orchestrator", "") + AUDITOR_AGENT = _roles.get("auditor", "") + RECRUITER_AGENT = _roles.get("recruiter", "") + _SYSTEM_AGENTS = {ORCHESTRATOR_AGENT, AUDITOR_AGENT, RECRUITER_AGENT} - {""} + LEAD_AGENT_NAMES = {name for name in _agents if name not in _SYSTEM_AGENTS} + ACTIVE_PROVIDER = _agents_cfg.get("default_provider", "none") + # Agents the orchestrator can dispatch work to. # Everything in agents.yaml that is not a system-role agent. LEAD_AGENT_NAMES: set[str] = { diff --git a/orchestration/orchestrator.py b/orchestration/orchestrator.py index 3bb96dc..e1275f0 100644 --- a/orchestration/orchestrator.py +++ b/orchestration/orchestrator.py @@ -19,6 +19,7 @@ from datetime import datetime import config import task_store +import tools as _tools import ui # --------------------------------------------------------------------------- @@ -51,6 +52,14 @@ class ProviderClient: response = await self.call_async(system, messages, model, temperature, max_tokens) yield ('content', response) + async def call_async_with_tools(self, system: str, messages: list[dict], model: str, + temperature: float, max_tokens: int, + tools: list[dict]) -> "_ToolMessage": + """Non-streaming call with tool schemas. Returns a _ToolMessage. + Base implementation ignores tools and wraps the text response.""" + text = await self.call_async(system, messages, model, temperature, max_tokens) + return _ToolMessage(content=text, tool_calls=None) + # --- OpenAI and any OpenAI-compatible endpoint --- # Covers AIPA_PROVIDER / {AGENT}_PROVIDER = openai or openai_compatible. @@ -78,6 +87,24 @@ class OpenAIClient(ProviderClient): self.call_async(system, messages, model, temperature, max_tokens) ) + async def call_async_with_tools(self, system, messages, model, temperature, max_tokens, tools): + """Non-streaming call with tool schemas. Returns a _ToolMessage.""" + full_messages = [{"role": "system", "content": system}] + messages + response = await self.client.chat.completions.create( + model=model, + temperature=temperature, + max_tokens=max_tokens, + messages=full_messages, + tools=tools, + tool_choice="auto", + extra_body=self.extra_body or None, + ) + msg = response.choices[0].message + return _ToolMessage( + content=msg.content or "", + tool_calls=msg.tool_calls or None, + ) + async def call_async_streaming(self, system, messages, model, temperature, max_tokens): """Stream via the OpenAI-compatible API, yielding thinking and content chunks. Thinking tokens arrive in delta.reasoning_content (Qwen3, DeepSeek R1 style); @@ -187,6 +214,13 @@ def build_client(agent_name: str) -> ProviderClient: # Data Classes # --------------------------------------------------------------------------- +@dataclass +class _ToolMessage: + """Minimal message returned by call_async_with_tools.""" + content: str + tool_calls: list | None # list of openai ToolCall objects, or None + + @dataclass class AgentState: """Runtime state for one named agent during a session.""" @@ -198,6 +232,7 @@ class AgentState: stateful: bool client: ProviderClient history: list[dict] = field(default_factory=list) + tools: list[str] = field(default_factory=list) # tool names from agents.yaml def add_user(self, content: str): self.history.append({"role": "user", "content": content}) @@ -289,6 +324,7 @@ def build_agent(name: str) -> AgentState: max_tokens=cfg["max_tokens"], stateful=cfg["stateful"], client=build_client(name), + tools=cfg.get("tools", []), ) @@ -304,6 +340,87 @@ def new_task_id() -> str: return f"T-{datetime.now().strftime('%Y%m%d')}-{_task_counter:03d}" +# --------------------------------------------------------------------------- +# Tool-Calling Loop +# --------------------------------------------------------------------------- + +async def _run_tool_loop( + agent: AgentState, + stream_mode: str = "deliverable", + dry_run: bool = False, +) -> str: + """ + Agentic loop for tool-enabled agents. + + Calls the agent with its tool schemas until the model stops issuing + tool_calls. Each tool invocation is displayed via ui.print_tool_call(). + The final text response is streamed via stream_agent_output (so the caller + gets the same visual treatment as a regular streaming call). + + The agent's history is updated with assistant tool-call messages and + tool result messages so that multi-turn tool use works correctly. + + Returns the final content string. + """ + if dry_run: + return f"[DRY RUN] Would run tool loop for {agent.name}" + + schemas = _tools.get_schemas(agent.tools) + + # Iterate until the model stops calling tools + while True: + msg: _ToolMessage = await agent.client.call_async_with_tools( + system=agent.system_prompt, + messages=agent.messages_for_call(), + model=agent.model, + temperature=agent.temperature, + max_tokens=agent.max_tokens, + tools=schemas, + ) + + if not msg.tool_calls: + # No more tool calls — stream final content + final_content = msg.content + + async def _emit_final(): + yield ('content', final_content) + + response = await ui.stream_agent_output(agent.name, _emit_final(), mode=stream_mode) + agent.add_assistant(response) + return response + + # Append the assistant message with tool_calls to history + # Build a serialisable representation of the tool-calls message + tool_calls_repr = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in msg.tool_calls + ] + agent.history.append({ + "role": "assistant", + "content": msg.content or "", + "tool_calls": tool_calls_repr, + }) + + # Execute each tool call and append results + for tc in msg.tool_calls: + tool_name = tc.function.name + args_json = tc.function.arguments + result_json = _tools.call(tool_name, args_json) + ui.print_tool_call(agent.name, tool_name, args_json, result_json) + agent.history.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": result_json, + }) + + # --------------------------------------------------------------------------- # Core Agent Caller # Uses the agent's own client — no shared client passed in. @@ -321,6 +438,13 @@ async def call_agent_async( agent.add_user(user_message) + # If the agent has tools, use the tool-calling loop instead of streaming directly + if agent.tools: + try: + return await _run_tool_loop(agent, stream_mode=stream_mode, dry_run=dry_run) + except NotImplementedError as e: + raise RuntimeError(str(e)) from e + try: if stream: response = await ui.stream_agent_output( @@ -687,6 +811,27 @@ async def cmd_history(session: Session, args: str) -> bool: return True +@command(["/reload"], "Reload agents.yaml and rebuild the agent roster for this session") +async def cmd_reload(session: Session, args: str) -> bool: + ui.print_system("Reloading agents configuration…") + config.reload_agents_config() + # Rebuild all agents with fresh config + session.orchestrator = build_agent(config.ORCHESTRATOR_AGENT) + session.auditor = build_agent(config.AUDITOR_AGENT) + session.leads = { + name: build_agent(name) for name in config.LEAD_AGENT_NAMES + } + # Re-inject standing brief into orchestrator + brief = load_standing_brief() + session.orchestrator.system_prompt = ( + session.orchestrator.system_prompt + + "\n\n---\n\n## Standing Brief (current)\n\n" + + brief + ) + ui.print_system("Agents reloaded.") + return True + + @command(["/thinking"], "Show the thinking output from the last response") async def cmd_thinking(session: Session, args: str) -> bool: last = ui.get_last_thinking() diff --git a/orchestration/requirements.txt b/orchestration/requirements.txt index 2d58274..65b54cf 100644 --- a/orchestration/requirements.txt +++ b/orchestration/requirements.txt @@ -9,6 +9,7 @@ python-dotenv>=1.0.0 # loads .env into os.environ at startup pyyaml>=6.0.0 # reads config/agents.yaml +ruamel.yaml>=0.18 # comment-preserving YAML round-trips (tools.py) openai>=1.50.0 # covers openai, openai_compatible, lmstudio, llamacpp rich>=13.0.0 # terminal UI (Iris) diff --git a/orchestration/tools.py b/orchestration/tools.py new file mode 100644 index 0000000..9b0b050 --- /dev/null +++ b/orchestration/tools.py @@ -0,0 +1,392 @@ +""" +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), + "provider": _config.agent_provider_name(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) + return { + "name": name, + "title": _config.agent_title(name), + "prompt_file": cfg.get("prompt_file"), + "provider": provider_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", []), + } + + +@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.0–1.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() + ] diff --git a/orchestration/ui.py b/orchestration/ui.py index 5bf7de4..b4ac39b 100644 --- a/orchestration/ui.py +++ b/orchestration/ui.py @@ -20,6 +20,7 @@ a header to know who is speaking — color and panel style make it immediate. from contextlib import contextmanager from datetime import datetime +import json as _json import config as _config @@ -889,3 +890,45 @@ def print_debug_toggle(enabled: bool): def print_audit_toggle(enabled: bool): state = "[green]ON[/green]" if enabled else "[dim]OFF[/dim]" console.print(f" [dim]Auto-audit (Vera):[/dim] {state}") + + +# --------------------------------------------------------------------------- +# Tool-call display +# --------------------------------------------------------------------------- + +def print_tool_call(agent_name: str, tool_name: str, arguments_json: str, result_json: str): + """ + Render a single tool invocation inline during an agent's tool-calling loop. + + Layout: + ⚙ AgentName › tool_name + arguments (pretty-printed JSON, dim) + ← result (pretty-printed JSON; red on parse error) + """ + color = _agent_color(agent_name) + + # Header line + console.print( + f" [dim]⚙[/dim] [{color}]{agent_name.capitalize()}[/{color}]" + f" [dim]›[/dim] [bold]{tool_name}[/bold]" + ) + + # Arguments + try: + args_pretty = _json.dumps(_json.loads(arguments_json), indent=2, ensure_ascii=False) + for line in args_pretty.splitlines(): + console.print(f" [dim]{line}[/dim]") + except Exception: + console.print(f" [dim]{arguments_json}[/dim]") + + # Result + try: + result_obj = _json.loads(result_json) + result_pretty = _json.dumps(result_obj, indent=2, ensure_ascii=False) + has_error = isinstance(result_obj, dict) and "error" in result_obj + style = "red" if has_error else "dim" + console.print(f" [dim]←[/dim]") + for line in result_pretty.splitlines(): + console.print(f" [{style}]{line}[/{style}]") + except Exception: + console.print(f" [dim]← {result_json}[/dim]")