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 060aaa2..be344ea 100644 Binary files a/orchestration/__pycache__/config.cpython-311.pyc and b/orchestration/__pycache__/config.cpython-311.pyc differ diff --git a/orchestration/__pycache__/orchestrator.cpython-311.pyc b/orchestration/__pycache__/orchestrator.cpython-311.pyc index e26df96..7b7d372 100644 Binary files a/orchestration/__pycache__/orchestrator.cpython-311.pyc and b/orchestration/__pycache__/orchestrator.cpython-311.pyc differ diff --git a/orchestration/__pycache__/tools.cpython-311.pyc b/orchestration/__pycache__/tools.cpython-311.pyc new file mode 100644 index 0000000..6208e65 Binary files /dev/null and b/orchestration/__pycache__/tools.cpython-311.pyc differ diff --git a/orchestration/__pycache__/ui.cpython-311.pyc b/orchestration/__pycache__/ui.cpython-311.pyc index 5351aae..e66f461 100644 Binary files a/orchestration/__pycache__/ui.cpython-311.pyc and b/orchestration/__pycache__/ui.cpython-311.pyc differ 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]")