817 lines
28 KiB
Python
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()
|