Tool calling added.

This commit is contained in:
vh
2026-04-03 09:01:01 -07:00
parent 46b10e8420
commit 2166af01be
11 changed files with 1492 additions and 0 deletions
+8
View File
@@ -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
+861
View File
@@ -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.
+42
View File
@@ -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] = {
+145
View File
@@ -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()
+1
View File
@@ -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)
+392
View File
@@ -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()
]
+43
View File
@@ -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]")