Files
AIPA/orchestration/orchestrator.py
T
2026-04-02 22:01:07 -07:00

817 lines
28 KiB
Python

"""
orchestrator.py — AIPA Session Orchestrator
Entry point for Principal → Miranda → Leads → Principal sessions.
Usage:
python orchestrator.py # interactive session
python orchestrator.py --dry-run # print routing without calling agents
python orchestrator.py --agent evelyn # open a direct session with a named agent
Requirements:
See requirements.txt. Install with: pip install -r requirements.txt
"""
import asyncio
import argparse
import re
from dataclasses import dataclass, field
from datetime import datetime
import config
import task_store
import ui
# ---------------------------------------------------------------------------
# Provider Clients
# Each agent builds its own client at session startup via build_client().
# Adding a new provider: implement the class, add a case to build_client().
# ---------------------------------------------------------------------------
class ProviderClient:
"""
Base / placeholder client.
Used when AIPA_PROVIDER=none or in dry-run mode.
Raises a clear error if actually called.
"""
def call(self, system: str, messages: list[dict], model: str,
temperature: float, max_tokens: int) -> str:
raise NotImplementedError(
"No provider is configured for this agent. "
"Set 'provider' on the agent or 'default_provider' in config/agents.yaml."
)
async def call_async(self, system: str, messages: list[dict], model: str,
temperature: float, max_tokens: int) -> str:
return self.call(system, messages, model, temperature, max_tokens)
# --- OpenAI and any OpenAI-compatible endpoint ---
# Covers AIPA_PROVIDER / {AGENT}_PROVIDER = openai or openai_compatible.
# Compatible servers: LM Studio, llama.cpp, Ollama (/v1 endpoint), Groq,
# Together AI, Mistral API, Anyscale, Fireworks, etc.
class OpenAIClient(ProviderClient):
def __init__(self, api_key: str, base_url: str | None, extra_body: dict | None = None):
from openai import AsyncOpenAI
self.client = AsyncOpenAI(api_key=api_key, base_url=base_url)
self.extra_body = extra_body or {}
async def call_async(self, system, messages, model, temperature, max_tokens):
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,
extra_body=self.extra_body or None,
)
return response.choices[0].message.content or ""
def call(self, system, messages, model, temperature, max_tokens):
return asyncio.run(
self.call_async(system, messages, model, temperature, max_tokens)
)
# --- Anthropic ---
# Uncomment when ready to use. Install: pip install anthropic
# class AnthropicClient(ProviderClient):
# def __init__(self, api_key: str):
# import anthropic
# self.client = anthropic.Anthropic(api_key=api_key)
#
# def call(self, system, messages, model, temperature, max_tokens):
# response = self.client.messages.create(
# model=model,
# max_tokens=max_tokens,
# system=system,
# messages=messages,
# temperature=temperature,
# )
# return response.content[0].text
#
# async def call_async(self, system, messages, model, temperature, max_tokens):
# loop = asyncio.get_event_loop()
# return await loop.run_in_executor(
# None, lambda: self.call(system, messages, model, temperature, max_tokens)
# )
# --- Ollama native API ---
# Uncomment when ready to use. No extra SDK required.
# class OllamaClient(ProviderClient):
# def __init__(self, base_url: str):
# import requests as req
# self._requests = req
# self.base_url = base_url
#
# def call(self, system, messages, model, temperature, max_tokens):
# full_messages = [{"role": "system", "content": system}] + messages
# response = self._requests.post(
# f"{self.base_url}/api/chat",
# json={
# "model": model,
# "stream": False,
# "options": {"temperature": temperature, "num_predict": max_tokens},
# "messages": full_messages,
# },
# timeout=config.LEAD_TIMEOUT_SECONDS,
# )
# response.raise_for_status()
# return response.json()["message"]["content"]
#
# async def call_async(self, system, messages, model, temperature, max_tokens):
# loop = asyncio.get_event_loop()
# return await loop.run_in_executor(
# None, lambda: self.call(system, messages, model, temperature, max_tokens)
# )
def build_client(agent_name: str) -> ProviderClient:
"""
Build the correct ProviderClient for a named agent.
Provider type is resolved from agents.yaml via config.
API keys and base URLs are resolved from .env via config.
"""
provider_type = config.agent_provider_type(agent_name)
if provider_type == "none":
return ProviderClient()
elif provider_type in ("openai", "openai_compatible"):
return OpenAIClient(
api_key=config.agent_api_key(agent_name),
base_url=config.agent_base_url(agent_name),
extra_body=config.agent_extra_body(agent_name),
)
# elif provider_type == "anthropic":
# return AnthropicClient(api_key=config.agent_api_key(agent_name))
# elif provider_type == "ollama":
# return OllamaClient(base_url=config.agent_base_url(agent_name))
else:
raise ValueError(
f"Agent '{agent_name}' has unrecognised provider type: {provider_type!r}. "
f"Check the provider definition in config/agents.yaml."
)
# ---------------------------------------------------------------------------
# Data Classes
# ---------------------------------------------------------------------------
@dataclass
class AgentState:
"""Runtime state for one named agent during a session."""
name: str
system_prompt: str
model: str
temperature: float
max_tokens: int
stateful: bool
client: ProviderClient
history: list[dict] = field(default_factory=list)
def add_user(self, content: str):
self.history.append({"role": "user", "content": content})
def add_assistant(self, content: str):
self.history.append({"role": "assistant", "content": content})
def messages_for_call(self) -> list[dict]:
return self.history if self.stateful else self.history[-2:]
@dataclass
class Task:
task_id: str
directive: str
assigned_to: str
brief: str
status: str = "pending"
output: str = ""
error: str = ""
# ---------------------------------------------------------------------------
# Prompt Loader
# ---------------------------------------------------------------------------
def load_system_prompt(prompt_file: str) -> str:
"""
Read a named agent's prompt file and extract the system prompt section.
Strips everything from '## Access Configuration' onward.
"""
path = config.PROMPTS_DIR / prompt_file
if not path.exists():
raise FileNotFoundError(f"Prompt file not found: {path}")
text = path.read_text(encoding="utf-8")
match = re.search(
r"## System Prompt\n(.+?)(?=\n## Access Configuration|\Z)",
text,
re.DOTALL,
)
if match:
return match.group(1).strip()
# Fallback: strip header metadata block
lines = text.splitlines()
in_header = True
body_lines = []
for line in lines:
if in_header and (line.startswith("**") or line.startswith("#")):
in_header = False
if not in_header:
body_lines.append(line)
return "\n".join(body_lines).strip()
def load_standing_brief() -> str:
path = config.STANDING_BRIEF_PATH
if path.exists() and path.stat().st_size > 64:
return path.read_text(encoding="utf-8")
# Fall back to template for a clean starting point
if config.BRIEF_TEMPLATE_PATH.exists():
return config.BRIEF_TEMPLATE_PATH.read_text(encoding="utf-8")
return "(No standing brief found — create docs/standing_brief.md to initialise.)"
def save_standing_brief(content: str):
config.STANDING_BRIEF_PATH.write_text(content, encoding="utf-8")
ui.print_system("Standing brief updated.")
# ---------------------------------------------------------------------------
# Agent Builder
# Resolves provider, model, key, and URL for each agent independently.
# ---------------------------------------------------------------------------
def build_agent(name: str) -> AgentState:
"""
Construct an AgentState for a named agent.
Each agent gets its own ProviderClient based on its own provider config.
"""
cfg = config.AGENT_CONFIGS[name]
return AgentState(
name=name,
system_prompt=load_system_prompt(cfg["prompt_file"]),
model=config.agent_model(name),
temperature=cfg["temperature"],
max_tokens=cfg["max_tokens"],
stateful=cfg["stateful"],
client=build_client(name),
)
# ---------------------------------------------------------------------------
# Task ID Generator
# ---------------------------------------------------------------------------
_task_counter = 0
def new_task_id() -> str:
global _task_counter
_task_counter += 1
return f"T-{datetime.now().strftime('%Y%m%d')}-{_task_counter:03d}"
# ---------------------------------------------------------------------------
# Core Agent Caller
# Uses the agent's own client — no shared client passed in.
# ---------------------------------------------------------------------------
async def call_agent_async(
agent: AgentState,
user_message: str,
dry_run: bool = False,
) -> str:
if dry_run:
return f"[DRY RUN] Would call {agent.name} ({config.agent_provider_name(agent.name)}) with: {user_message[:100]}..."
agent.add_user(user_message)
try:
response = await agent.client.call_async(
system=agent.system_prompt,
messages=agent.messages_for_call(),
model=agent.model,
temperature=agent.temperature,
max_tokens=agent.max_tokens,
)
except NotImplementedError as e:
raise RuntimeError(str(e)) from e
agent.add_assistant(response)
if config.DEBUG_PRINT_AGENT_OUTPUTS:
ui.print_agent_panel(agent.name, response)
return response
# ---------------------------------------------------------------------------
# Task Brief Parser
# ---------------------------------------------------------------------------
def parse_task_briefs(orchestrator_response: str, task_id_prefix: str) -> list[Task]:
tasks = []
blocks = re.findall(
r"TASK BRIEF\s*\n(.+?)(?=TASK BRIEF|\Z)",
orchestrator_response,
re.DOTALL | re.IGNORECASE,
)
for i, block in enumerate(blocks):
to_match = re.search(r"To:\s*(.+)", block)
if not to_match:
continue
to_text = to_match.group(1).lower()
lead = next(
(name for name in config.LEAD_AGENT_NAMES if name in to_text), None
)
if not lead:
continue
task_id = f"{task_id_prefix}-{chr(65 + i)}"
tasks.append(Task(
task_id=task_id,
directive=to_text,
assigned_to=lead,
brief=f"TASK BRIEF\n{block.strip()}",
))
return tasks
# ---------------------------------------------------------------------------
# Lead Dispatcher
# ---------------------------------------------------------------------------
async def dispatch_to_lead(
lead: AgentState,
task: Task,
progress=None,
progress_tasks: dict = None,
dry_run: bool = False,
session_id: str = "",
) -> Task:
task.status = "in_progress"
if progress is not None and progress_tasks is not None:
ui.mark_task_running(progress, progress_tasks, task.task_id, lead.name)
if session_id:
task_store.update_task(task, session_id)
try:
output = await call_agent_async(lead, task.brief, dry_run=dry_run)
task.output = output
task.status = "complete"
except Exception as e:
task.error = str(e)
task.status = "error"
ui.print_error(f"{lead.name} failed on {task.task_id}: {e}")
finally:
if session_id:
task_store.update_task(task, session_id)
if progress is not None and progress_tasks is not None:
ui.mark_task_done(
progress, progress_tasks, task.task_id, lead.name,
error=(task.status == "error"),
)
return task
async def dispatch_all_leads(
leads: dict[str, AgentState],
tasks: list[Task],
dry_run: bool = False,
session_id: str = "",
) -> list[Task]:
by_lead: dict[str, list[Task]] = {}
for task in tasks:
by_lead.setdefault(task.assigned_to, []).append(task)
with ui.lead_dispatch_progress(tasks) as (progress, prog_tasks):
async def run_lead_tasks(lead_name: str, lead_tasks: list[Task]):
lead = leads[lead_name]
results = []
for task in lead_tasks:
result = await dispatch_to_lead(
lead, task,
progress=progress, progress_tasks=prog_tasks,
dry_run=dry_run,
session_id=session_id,
)
results.append(result)
return results
coroutines = [
run_lead_tasks(lead_name, lead_tasks)
for lead_name, lead_tasks in by_lead.items()
]
grouped_results = await asyncio.gather(*coroutines)
return [task for group in grouped_results for task in group]
# ---------------------------------------------------------------------------
# Vera Audit
# ---------------------------------------------------------------------------
async def run_auditor(
auditor: AgentState,
deliverable: str,
task_id: str,
dry_run: bool = False,
) -> str:
audit_request = (
f"Please review the following deliverable and return your Audit Memo.\n\n"
f"Task ID: {task_id}\n\n"
f"{deliverable}"
)
auditor.history = [] # Auditor is stateless — fresh call each time
return await call_agent_async(auditor, audit_request, dry_run=dry_run)
# ---------------------------------------------------------------------------
# Standing Brief Update
# ---------------------------------------------------------------------------
async def update_standing_brief(
orchestrator: AgentState,
session_summary: str,
dry_run: bool = False,
) -> str:
current_brief = load_standing_brief()
update_request = (
f"The session is closing. Please produce a fully updated Standing Brief.\n\n"
f"Current brief:\n{current_brief}\n\n"
f"Session summary:\n{session_summary}\n\n"
f"Return the complete updated Standing Brief document, ready to save. "
f"Increment the version number, update the date, and write the new session "
f"log entry at the top of Section 9. Archive anything older than "
f"{config.SESSION_LOG_RETENTION} sessions."
)
return await call_agent_async(orchestrator, update_request, dry_run=dry_run)
# ---------------------------------------------------------------------------
# Session
# ---------------------------------------------------------------------------
class Session:
def __init__(self, dry_run: bool = False):
self.dry_run = dry_run
self.session_log: list[str] = []
self.directive_count = 0
self.session_start = datetime.now()
self.session_id = self.session_start.strftime("%Y%m%d_%H%M%S")
self.last_deliverable: str = ""
self.last_task_id: str = ""
self.all_tasks: list[Task] = []
# Runtime flags (togglable via slash commands)
self.auto_audit = config.VERA_AUTO_AUDIT
self.debug = config.DEBUG_PRINT_AGENT_OUTPUTS
config.ensure_runtime_dirs()
ui.print_system("Loading agents…")
self.orchestrator = build_agent(config.ORCHESTRATOR_AGENT)
self.auditor = build_agent(config.AUDITOR_AGENT)
self.leads = {
name: build_agent(name) for name in config.LEAD_AGENT_NAMES
}
# Inject standing brief into orchestrator's context
brief = load_standing_brief()
self.orchestrator.system_prompt = (
self.orchestrator.system_prompt
+ "\n\n---\n\n## Standing Brief (current)\n\n"
+ brief
)
ui.print_system("Session ready.")
async def run_directive(self, directive: str) -> tuple[str, str]:
task_id = new_task_id()
self.directive_count += 1
self.last_task_id = task_id
self.session_log.append(f"Directive received: {directive[:120]} [{task_id}]")
ui.print_task_received(task_id, directive)
# ── Step 1: Orchestrator receives the directive ─────────────────────
cos_prompt = (
f"The Principal has issued the following directive. "
f"Analyse it, determine what work is needed, and either:\n"
f" (a) Respond directly if no Lead work is required, or\n"
f" (b) Issue TASK BRIEF blocks for the appropriate Leads.\n\n"
f"Directive: {directive}"
)
cos_response = await call_agent_async(
self.orchestrator, cos_prompt, dry_run=self.dry_run
)
# ── Step 2: Parse any task briefs the orchestrator issued ───────────
tasks = parse_task_briefs(cos_response, task_id)
if not tasks:
ui.print_direct_response_notice()
deliverable = cos_response
else:
# ── Step 3: Persist task records then dispatch ──────────────────
ui.print_task_dispatch_plan(tasks)
task_store.record_tasks(tasks, self.session_id, directive)
self.all_tasks.extend(tasks)
completed_tasks = await dispatch_all_leads(
self.leads, tasks, dry_run=self.dry_run, session_id=self.session_id,
)
# ── Step 4: Orchestrator synthesizes ────────────────────────────
lead_outputs_text = "\n\n".join(
f"--- Output from {t.assigned_to.upper()} [{t.task_id}] ---\n{t.output}"
if t.status == "complete"
else f"--- {t.assigned_to.upper()} [{t.task_id}] FAILED: {t.error} ---"
for t in completed_tasks
)
synthesis_prompt = (
f"All Lead outputs have returned for {task_id}. "
f"Please synthesize these into a final deliverable for the Principal.\n\n"
f"{lead_outputs_text}"
)
with ui.synthesis_progress():
deliverable = await call_agent_async(
self.orchestrator, synthesis_prompt, dry_run=self.dry_run
)
dispatched_leads = sorted({t.assigned_to for t in completed_tasks})
self.session_log.append(
f"Leads dispatched: {', '.join(dispatched_leads)} | "
f"Tasks: {', '.join(t.task_id for t in completed_tasks)}"
)
self.last_deliverable = deliverable
# ── Step 5: Auditor review ──────────────────────────────────────────
audit_memo = ""
if self.auto_audit:
try:
with ui.audit_progress():
audit_memo = await run_auditor(
self.auditor, deliverable, task_id, dry_run=self.dry_run
)
self.session_log.append(f"Audit complete for {task_id}.")
except RuntimeError as e:
ui.print_warning(f"Audit skipped — {e}")
return deliverable, audit_memo
async def close(self):
summary = "\n".join(self.session_log)
with ui.brief_update_progress():
updated_brief = await update_standing_brief(
self.orchestrator, summary, dry_run=self.dry_run
)
if not self.dry_run:
save_standing_brief(updated_brief)
ui.print_session_footer(self.directive_count)
# ---------------------------------------------------------------------------
# Slash Command Registry
# ---------------------------------------------------------------------------
COMMANDS: list[tuple[list[str], str, object]] = []
def command(names: list[str], description: str):
def decorator(fn):
COMMANDS.append((names, description, fn))
return fn
return decorator
@command(["/help", "/?"], "Show this command list")
async def cmd_help(session: Session, args: str) -> bool:
rows = [(", ".join(names), desc) for names, desc, _ in COMMANDS]
ui.print_help(rows)
return True
@command(["/status"], "Session stats, settings, and log")
async def cmd_status(session: Session, args: str) -> bool:
ui.print_status(
session_start=session.session_start,
directive_count=session.directive_count,
session_log=session.session_log,
auto_audit=session.auto_audit,
debug=session.debug,
dry_run=session.dry_run,
)
return True
@command(["/brief"], "Show the current standing brief")
async def cmd_brief(session: Session, args: str) -> bool:
ui.print_standing_brief(load_standing_brief())
return True
@command(["/reset-brief"], "Replace the standing brief with the blank template")
async def cmd_reset_brief(session: Session, args: str) -> bool:
if not config.BRIEF_TEMPLATE_PATH.exists():
ui.print_error(f"Template not found: {config.BRIEF_TEMPLATE_PATH}")
return True
confirm = ui.console.input(
" [warning]This will overwrite the standing brief. Type YES to confirm:[/warning] "
).strip()
if confirm != "YES":
ui.print_system("Reset cancelled.")
return True
template = config.BRIEF_TEMPLATE_PATH.read_text(encoding="utf-8")
save_standing_brief(template)
# Re-inject the fresh brief into the orchestrator's context for this session
session.orchestrator.system_prompt = (
load_system_prompt(config.AGENT_CONFIGS[config.ORCHESTRATOR_AGENT]["prompt_file"])
+ "\n\n---\n\n## Standing Brief (current)\n\n"
+ template
)
ui.print_system("Standing brief reset to template.")
return True
@command(["/agents"], "Show agent roster with provider and model assignments")
async def cmd_agents(session: Session, args: str) -> bool:
ui.print_agents(config.AGENT_CONFIGS, config.ACTIVE_PROVIDER)
return True
@command(["/history"], "Show directives processed this session")
async def cmd_history(session: Session, args: str) -> bool:
ui.print_history(session.session_log)
return True
@command(["/audit"], "Re-run Vera's audit on the last deliverable")
async def cmd_audit(session: Session, args: str) -> bool:
if not session.last_deliverable:
ui.print_warning("No deliverable yet — issue a directive first.")
return True
ui.print_system("Running audit on last deliverable…")
with ui.audit_progress():
memo = await run_auditor(
session.auditor, session.last_deliverable,
session.last_task_id, dry_run=session.dry_run,
)
ui.print_audit_memo(memo, task_id=session.last_task_id)
return True
@command(["/debug"], "Toggle raw agent output")
async def cmd_debug(session: Session, args: str) -> bool:
session.debug = not session.debug
config.DEBUG_PRINT_AGENT_OUTPUTS = session.debug
ui.print_debug_toggle(session.debug)
return True
@command(["/tasks"], "Show tasks dispatched this session and their status")
async def cmd_tasks(session: Session, args: str) -> bool:
ui.print_tasks(session.all_tasks, session_id=session.session_id)
return True
@command(["/autoaudit"], "Toggle automatic Vera audit after each directive")
async def cmd_autoaudit(session: Session, args: str) -> bool:
session.auto_audit = not session.auto_audit
ui.print_audit_toggle(session.auto_audit)
return True
@command(["/clear"], "Clear the terminal")
async def cmd_clear(session: Session, args: str) -> bool:
ui.console.clear()
ui.print_session_header()
return True
@command(["/quit", "/exit", "/q"], "Close the session and update the standing brief")
async def cmd_quit(session: Session, args: str) -> bool:
return False
def _dispatch_command(name: str):
name_lower = name.lower()
for names, _, handler in COMMANDS:
if name_lower in names:
return handler
return None
# ---------------------------------------------------------------------------
# Direct Agent Session
# Opens an interactive REPL with one named agent.
# ---------------------------------------------------------------------------
async def direct_agent_session(agent_name: str, dry_run: bool = False):
if agent_name not in config.AGENT_CONFIGS:
ui.print_error(f"Unknown agent: {agent_name!r}")
ui.print_system(f"Available agents: {', '.join(config.AGENT_CONFIGS)}")
return
agent = build_agent(agent_name)
provider = config.agent_provider_name(agent_name)
ui.print_session_header()
ui.print_system(
f"Direct session with {agent_name.capitalize()} "
f"[dim]({provider} / {agent.model or 'no model set'})[/dim]. "
f"Type /quit to end."
)
while True:
try:
user_input = ui.get_directive()
except (EOFError, KeyboardInterrupt):
break
if not user_input or user_input.lower() in ("/quit", "/exit", "/q"):
break
response = await call_agent_async(agent, user_input, dry_run=dry_run)
ui.print_agent_panel(agent_name, response)
ui.print_session_footer(0)
# ---------------------------------------------------------------------------
# Principal REPL
# ---------------------------------------------------------------------------
async def principal_session(dry_run: bool = False):
ui.print_session_header()
if dry_run:
ui.print_dry_run_banner()
session = Session(dry_run=dry_run)
while True:
try:
raw = ui.get_directive()
except (EOFError, KeyboardInterrupt):
ui.console.print()
break
if not raw:
continue
# ── Slash command dispatch ──────────────────────────────────────────
if raw.startswith("/"):
parts = raw.split(None, 1)
cmd_name = parts[0]
cmd_args = parts[1] if len(parts) > 1 else ""
handler = _dispatch_command(cmd_name)
if handler is None:
ui.print_error(f"Unknown command: {cmd_name} (try /help)")
continue
should_continue = await handler(session, cmd_args)
if not should_continue:
break
continue
# ── Directive ───────────────────────────────────────────────────────
try:
deliverable, audit_memo = await session.run_directive(raw)
except RuntimeError as e:
ui.print_error(str(e))
break
ui.print_deliverable(deliverable, task_id=session.last_task_id)
if audit_memo:
ui.print_audit_memo(audit_memo, task_id=session.last_task_id)
await session.close()
# ---------------------------------------------------------------------------
# Entry Point
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="AIPA Orchestrator — run a Principal session."
)
parser.add_argument(
"--dry-run", action="store_true",
help="Trace routing without making provider API calls.",
)
parser.add_argument(
"--agent", type=str, metavar="NAME",
help="Open a direct session with a named agent (e.g. evelyn, atlas).",
)
args = parser.parse_args()
if args.agent:
asyncio.run(direct_agent_session(args.agent, dry_run=args.dry_run))
else:
asyncio.run(principal_session(dry_run=args.dry_run))
if __name__ == "__main__":
main()