Tool calling added.
This commit is contained in:
@@ -172,6 +172,14 @@ agents:
|
|||||||
temperature: 0.7 # Qwen3 non-thinking recommendation
|
temperature: 0.7 # Qwen3 non-thinking recommendation
|
||||||
max_tokens: 16384
|
max_tokens: 16384
|
||||||
stateful: true
|
stateful: true
|
||||||
|
tools:
|
||||||
|
- list_tools
|
||||||
|
- list_agents
|
||||||
|
- read_agent_config
|
||||||
|
- upsert_agent_definition
|
||||||
|
- read_agent_prompt
|
||||||
|
- write_agent_prompt
|
||||||
|
- list_providers
|
||||||
|
|
||||||
atlas:
|
atlas:
|
||||||
title: Director of Research
|
title: Director of Research
|
||||||
|
|||||||
@@ -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_<id>.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_<id>.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/<name>_<role>.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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -296,10 +296,52 @@ AGENT_CONFIGS: dict[str, dict] = {
|
|||||||
"temperature": adef["temperature"],
|
"temperature": adef["temperature"],
|
||||||
"max_tokens": adef["max_tokens"],
|
"max_tokens": adef["max_tokens"],
|
||||||
"stateful": adef["stateful"],
|
"stateful": adef["stateful"],
|
||||||
|
"tools": list(adef.get("tools") or []),
|
||||||
}
|
}
|
||||||
for name, adef in _agents.items()
|
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.
|
# Agents the orchestrator can dispatch work to.
|
||||||
# Everything in agents.yaml that is not a system-role agent.
|
# Everything in agents.yaml that is not a system-role agent.
|
||||||
LEAD_AGENT_NAMES: set[str] = {
|
LEAD_AGENT_NAMES: set[str] = {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
import config
|
import config
|
||||||
import task_store
|
import task_store
|
||||||
|
import tools as _tools
|
||||||
import ui
|
import ui
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -51,6 +52,14 @@ class ProviderClient:
|
|||||||
response = await self.call_async(system, messages, model, temperature, max_tokens)
|
response = await self.call_async(system, messages, model, temperature, max_tokens)
|
||||||
yield ('content', response)
|
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 ---
|
# --- OpenAI and any OpenAI-compatible endpoint ---
|
||||||
# Covers AIPA_PROVIDER / {AGENT}_PROVIDER = openai or openai_compatible.
|
# 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)
|
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):
|
async def call_async_streaming(self, system, messages, model, temperature, max_tokens):
|
||||||
"""Stream via the OpenAI-compatible API, yielding thinking and content chunks.
|
"""Stream via the OpenAI-compatible API, yielding thinking and content chunks.
|
||||||
Thinking tokens arrive in delta.reasoning_content (Qwen3, DeepSeek R1 style);
|
Thinking tokens arrive in delta.reasoning_content (Qwen3, DeepSeek R1 style);
|
||||||
@@ -187,6 +214,13 @@ def build_client(agent_name: str) -> ProviderClient:
|
|||||||
# Data Classes
|
# 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
|
@dataclass
|
||||||
class AgentState:
|
class AgentState:
|
||||||
"""Runtime state for one named agent during a session."""
|
"""Runtime state for one named agent during a session."""
|
||||||
@@ -198,6 +232,7 @@ class AgentState:
|
|||||||
stateful: bool
|
stateful: bool
|
||||||
client: ProviderClient
|
client: ProviderClient
|
||||||
history: list[dict] = field(default_factory=list)
|
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):
|
def add_user(self, content: str):
|
||||||
self.history.append({"role": "user", "content": content})
|
self.history.append({"role": "user", "content": content})
|
||||||
@@ -289,6 +324,7 @@ def build_agent(name: str) -> AgentState:
|
|||||||
max_tokens=cfg["max_tokens"],
|
max_tokens=cfg["max_tokens"],
|
||||||
stateful=cfg["stateful"],
|
stateful=cfg["stateful"],
|
||||||
client=build_client(name),
|
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}"
|
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
|
# Core Agent Caller
|
||||||
# Uses the agent's own client — no shared client passed in.
|
# 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)
|
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:
|
try:
|
||||||
if stream:
|
if stream:
|
||||||
response = await ui.stream_agent_output(
|
response = await ui.stream_agent_output(
|
||||||
@@ -687,6 +811,27 @@ async def cmd_history(session: Session, args: str) -> bool:
|
|||||||
return True
|
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")
|
@command(["/thinking"], "Show the thinking output from the last response")
|
||||||
async def cmd_thinking(session: Session, args: str) -> bool:
|
async def cmd_thinking(session: Session, args: str) -> bool:
|
||||||
last = ui.get_last_thinking()
|
last = ui.get_last_thinking()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
python-dotenv>=1.0.0 # loads .env into os.environ at startup
|
python-dotenv>=1.0.0 # loads .env into os.environ at startup
|
||||||
pyyaml>=6.0.0 # reads config/agents.yaml
|
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
|
openai>=1.50.0 # covers openai, openai_compatible, lmstudio, llamacpp
|
||||||
rich>=13.0.0 # terminal UI (Iris)
|
rich>=13.0.0 # terminal UI (Iris)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
]
|
||||||
@@ -20,6 +20,7 @@ a header to know who is speaking — color and panel style make it immediate.
|
|||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import json as _json
|
||||||
|
|
||||||
import config as _config
|
import config as _config
|
||||||
|
|
||||||
@@ -889,3 +890,45 @@ def print_debug_toggle(enabled: bool):
|
|||||||
def print_audit_toggle(enabled: bool):
|
def print_audit_toggle(enabled: bool):
|
||||||
state = "[green]ON[/green]" if enabled else "[dim]OFF[/dim]"
|
state = "[green]ON[/green]" if enabled else "[dim]OFF[/dim]"
|
||||||
console.print(f" [dim]Auto-audit (Vera):[/dim] {state}")
|
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]")
|
||||||
|
|||||||
Reference in New Issue
Block a user