feat(orchestration): multi-angle workflow — 5+ parallel subagents, converge, log + index runs

- skills/multi-angle-orchestration/SKILL.md: SOP for single-source fan-out
  (delegate_task leaf workers, NO external AIs) of >=5 angles + converge.
- brain-core: ingest orchestration-runs/*.json as source 'orchestration'
  so past runs are searchable in Brain Search.
- server.py: POST /api/orchestrate (optional headless fan-out of the 3
  built-in agents) + GET /api/orchestrate/runs; persist runs to
  data/orchestration-runs/<id>.json and upsert to brain.
- dashboard/pages/orchestration.js: launch runs + review past runs.
- skills/multi-agent-run/SKILL.md + api helpers + nav entry.
This commit is contained in:
Austin 2026-07-25 18:07:51 -07:00
parent cce56eed02
commit 29eaa7f801
8 changed files with 417 additions and 0 deletions

View File

@ -168,11 +168,39 @@ def ingest_chat(conn: sqlite3.Connection, path: Path | None = None) -> int:
return count
def ingest_orchestration(conn: sqlite3.Connection, root: Path | None = None) -> int:
"""Index multi-agent orchestration runs (my delegate_task fan-outs) so
past 'spin up N agents on a goal' runs are searchable in the brain."""
root = root or (BASE_DIR / "data" / "orchestration-runs")
if not root.exists():
return 0
count = 0
for f in sorted(root.glob("*.json")):
try:
d = json.loads(f.read_text(encoding="utf-8", errors="replace"))
except (json.JSONDecodeError, OSError):
continue
goal = d.get("goal", "")
synthesis = d.get("synthesis", "") or ""
angles = d.get("angles", {}) or {}
angle_blob = "\n".join(f"{a}: {p}" for a, p in angles.items())
agents = ", ".join(d.get("agents", []) or [])
content = f"GOAL: {goal}\n\nANGLES ({agents}):\n{angle_blob}\n\nSYNTHESIS:\n{synthesis}"
rel = str(f.relative_to(BASE_DIR))
upsert_doc(conn, source="orchestration", source_path=rel,
title=f"Orchestration: {goal[:80]}", content=content,
agent=d.get("converge_agent") or "orchestrate",
updated_at=d.get("timestamp"))
count += 1
return count
def ingest_all(conn: sqlite3.Connection) -> dict:
return {
"brain": ingest_brain(conn),
"skill-learning": ingest_skill_learnings(conn),
"chat": ingest_chat(conn),
"orchestration": ingest_orchestration(conn),
}

View File

@ -97,6 +97,9 @@ const api = {
getBrainIndexSearch: (q, source = '', limit = 20) => api.get(`/api/brain-index/search?q=${encodeURIComponent(q)}&limit=${limit}${source ? `&source=${encodeURIComponent(source)}` : ''}`),
getBrainIndexStats: () => api.get('/api/brain-index/stats'),
brainIndexIngest: () => api.post('/api/brain-index/ingest', {}),
// Multi-Agent Orchestration (fan-out + converge)
orchestrate: (payload) => api.post('/api/orchestrate', payload),
getOrchestrationRuns: (limit = 50) => api.get(`/api/orchestrate/runs?limit=${limit}`),
// Agent Registry
getAgents: () => api.get('/api/agents'),
registerAgent: (data) => api.post('/api/agents/register', data),

View File

@ -51,6 +51,7 @@
<a href="#learning-analytics" class="nav-item" data-page="learning-analytics"><span class="nav-icon">📊</span><span class="nav-label">Learning Analytics</span></a>
<a href="#agent-insights" class="nav-item" data-page="agent-insights"><span class="nav-icon">🧠</span><span class="nav-label">Agent Insights</span></a>
<a href="#brain-search" class="nav-item" data-page="brain-search"><span class="nav-icon">🔍</span><span class="nav-label">Brain Search</span></a>
<a href="#orchestration" class="nav-item" data-page="orchestration"><span class="nav-icon">🕸</span><span class="nav-label">Multi-Agent Run</span></a>
<a href="#session-replay" class="nav-item" data-page="session-replay"><span class="nav-icon">🔄</span><span class="nav-label">Session Replay</span></a>
<a href="#agent-time" class="nav-item" data-page="agent-time"><span class="nav-icon"></span><span class="nav-label">Agent Time</span></a>
<div class="sidebar-section"><div class="sidebar-section-label">Management</div></div>

View File

@ -0,0 +1,92 @@
// Multi-Agent Orchestration — fan-out N agents on one goal, then converge.
async function renderOrchestration() {
const content = document.getElementById('pageContent');
content.innerHTML = `
<div class="page-header">
<div class="page-header-left">
<h1 class="page-title">Multi-Agent Run</h1>
<p class="page-subtitle">Spin up opencode, Gemini & Hermes on the same goal from different angles, then converge</p>
</div>
</div>
<div class="card mb-4">
<div class="page-subtitle" style="margin-bottom:8px">New orchestration run</div>
<label class="form-label">Project goal</label>
<textarea id="orchGoal" class="form-input" rows="3" placeholder="e.g. Build a real-time sync layer between the dashboard and the brain index"></textarea>
<details class="mt-2">
<summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">Customize angles (optional)</summary>
<div class="mt-2">
<label class="form-label">opencode angle</label>
<input id="orchOpencode" class="form-input mb-2" placeholder="implementation focus" />
<label class="form-label">gemini angle</label>
<input id="orchGemini" class="form-input mb-2" placeholder="research focus" />
<label class="form-label">hermes angle</label>
<input id="orchHermes" class="form-input" placeholder="coordination focus" />
</div>
</details>
<button class="btn btn-primary mt-3" id="orchRunBtn" onclick="orchRun()">🚀 Launch agents</button>
</div>
<div id="orchResult"></div>
<div class="page-subtitle mt-4">Past runs</div>
<div id="orchRuns"></div>
`;
await orchLoadRuns();
}
async function orchLoadRuns() {
const box = document.getElementById('orchRuns');
try {
const d = await api.getOrchestrationRuns(30);
if (!d.runs.length) { box.innerHTML = '<div class="empty-state"><div class="empty-state-title">No runs yet</div></div>'; return; }
box.innerHTML = d.runs.map(r => `
<div class="card mb-2" style="border-left:4px solid var(--accent)">
<div class="flex items-center gap-2">
<strong style="font-size:13px">${escapeHtml(r.goal || '(no goal)')}</strong>
<span class="badge" style="font-size:10px">${escapeHtml((r.agents||[]).join(' + '))}</span>
<span class="badge badge-accent" style="font-size:10px"> ${escapeHtml(r.converge_agent||'')}</span>
</div>
<div style="font-size:11px;color:var(--text-muted)">${escapeHtml(r.run_id||'')} · ${escapeHtml(r.timestamp||'')}</div>
</div>`).join('');
} catch (e) {
box.innerHTML = `<div class="empty-state"><div class="empty-state-title">Could not load runs</div></div>`;
}
}
async function orchRun() {
const goal = document.getElementById('orchGoal').value.trim();
const btn = document.getElementById('orchRunBtn');
if (!goal) { alert('Enter a project goal first.'); return; }
const angles = {};
const oc = document.getElementById('orchOpencode').value.trim();
const gm = document.getElementById('orchGemini').value.trim();
const hm = document.getElementById('orchHermes').value.trim();
if (oc) angles.opencode = oc;
if (gm) angles.gemini = gm;
if (hm) angles.hermes = hm;
btn.disabled = true; btn.textContent = 'Running agents…';
const res = document.getElementById('orchResult');
res.innerHTML = '<div class="loading"><div class="loading-spinner"></div><span>Fanning out agents & converging…</span></div>';
try {
const data = await api.orchestrate({ goal, angles: Object.keys(angles).length ? angles : undefined });
const cards = (data.outputs && Object.entries(data.outputs) || [])
.map(([a, o]) => `
<div class="card mb-2">
<div class="flex items-center gap-2 mb-1"><span class="badge badge-accent" style="font-size:10px">${escapeHtml(a)}</span><span style="font-size:11px;color:var(--text-muted)">angle output</span></div>
<div style="font-size:12px;white-space:pre-wrap;max-height:240px;overflow:auto">${escapeHtml((o||'').slice(0,2000))}</div>
</div>`).join('');
res.innerHTML = `
<div class="card mb-3" style="border-left:4px solid var(--accent)">
<div class="page-subtitle"> Synthesis (via ${escapeHtml(data.converge_agent||'')})</div>
<div style="font-size:13px;white-space:pre-wrap">${escapeHtml(data.synthesis || '(no synthesis)')}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:6px">run_id: ${escapeHtml(data.run_id||'')} · saved to ${escapeHtml(data.run_file||'')}</div>
</div>
<details><summary style="cursor:pointer;font-size:12px;color:var(--text-muted)">Show per-agent angle outputs</summary>
<div class="mt-2">${cards}</div>
</details>`;
await orchLoadRuns();
} catch (e) {
res.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠</div><div class="empty-state-title">Orchestration failed</div><div class="empty-state-desc">${escapeHtml(e.message)}</div></div>`;
} finally {
btn.disabled = false; btn.textContent = '🚀 Launch agents';
}
}

View File

@ -119,6 +119,7 @@ const PAGE_TITLES = {
cost: { title: 'Cost Analytics', breadcrumb: 'Usage & spending' },
'agent-insights': { title: 'Agent Insights', breadcrumb: 'AI usage analytics' },
'brain-search': { title: 'Brain Search', breadcrumb: 'Unified knowledge index' },
'orchestration': { title: 'Multi-Agent Run', breadcrumb: 'Fan-out + converge agents' },
plugins: { title: 'Plugin Registry', breadcrumb: 'Manage plugins' },
backups: { title: 'Backups', breadcrumb: 'Disaster recovery' },
prompts: { title: 'Prompt Library', breadcrumb: 'Reusable templates' },

166
server.py
View File

@ -20,6 +20,7 @@ import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from concurrent.futures import ThreadPoolExecutor
from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
@ -92,6 +93,13 @@ class SkillRunRequest(BaseModel):
input: Optional[str] = ""
agent: Optional[str] = "auto"
class OrchestrateRequest(BaseModel):
goal: str
angles: Optional[dict] = None # {agent: custom_angle_prompt}
agents: Optional[list] = None # override default [opencode, gemini, hermes]
converge_agent: Optional[str] = "hermes"
converge_prompt: Optional[str] = None # custom synthesis instruction
class SkillCreate(BaseModel):
name: str
skill_md: str = ""
@ -915,6 +923,164 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
"message": f"Skill '{name}' completed via {agent_choice}",
}
# ─── Routes: Multi-Agent Orchestration (fan-out + converge) ─────────
ORCHESTRATION_DIR = BASE_DIR / "data" / "orchestration-runs"
def _run_angle(agent: str, angle_prompt: str):
"""Run a single agent on its angle. Returns (agent, output, error)."""
try:
out = execute_agent(agent, angle_prompt)
return agent, out, None
except Exception as e:
return agent, f"⚠ Error from {agent}: {e}", str(e)
@app.post("/api/orchestrate")
def orchestrate_multi_agent(req: OrchestrateRequest):
"""Spin up multiple agents on the same project goal from different angles,
then converge their outputs into a single synthesis.
Default angles (one per built-in agent):
- opencode -> implementation plan / code for the goal
- gemini -> research, analysis, risk/feasibility
- hermes -> coordination, memory, integration plan
A convergence step feeds all angle outputs to `converge_agent`
(default hermes) to produce the unified result.
"""
if not req.goal or not req.goal.strip():
raise HTTPException(422, "goal is required")
agents = req.agents or ["opencode", "gemini", "hermes"]
# Validate agents exist in the registry
registry = load_agent_registry()
unknown = [a for a in agents if a not in registry]
if unknown:
raise HTTPException(400, f"unknown agent(s): {unknown}")
# Default angle prompts per role
default_angles = {
"opencode": (
f"PROJECT GOAL: {req.goal}\n\n"
"You are the IMPLEMENTATION agent. Produce a concrete implementation "
"approach: architecture, files to create/modify, key functions, and a "
"step-by-step build plan. Be specific and actionable. Do not do the "
"other agents' jobs — focus on engineering."
),
"gemini": (
f"PROJECT GOAL: {req.goal}\n\n"
"You are the RESEARCH / ANALYSIS agent. Investigate the problem space: "
"relevant approaches, trade-offs, risks, feasibility, and any external "
"facts or references. Do not write implementation code — focus on "
"analysis and evidence."
),
"hermes": (
f"PROJECT GOAL: {req.goal}\n\n"
"You are the COORDINATION / MEMORY agent. Define how the work should be "
"sequenced, what shared state/memory is needed, how the implementation "
"and research results integrate, and any scheduling/coordination plan. "
"Focus on orchestration, not on coding or research yourself."
),
}
angle_prompts = {}
for a in agents:
if req.angles and a in req.angles and req.angles[a]:
angle_prompts[a] = req.angles[a]
else:
angle_prompts[a] = default_angles.get(a, f"PROJECT GOAL: {req.goal}\n\nAddress this goal from your perspective as {a}.")
# Fan-out: run all angles in parallel
outputs = {}
with ThreadPoolExecutor(max_workers=len(agents)) as ex:
futures = {ex.submit(_run_angle, a, angle_prompts[a]): a for a in agents}
for fut in futures:
a = futures[fut]
_ag, out, _err = fut.result()
outputs[a] = out
# Converge
converge = req.converge_agent or "hermes"
if converge not in registry:
converge = agents[0]
angle_block = "\n\n".join(
f"### {a.upper()} ANGLE OUTPUT:\n{outputs[a]}" for a in agents
)
synthesis_prompt = req.converge_prompt or (
f"PROJECT GOAL: {req.goal}\n\n"
"You are the CONVERGENCE agent. Below are the outputs of multiple agents "
"who each tackled the same project goal from a different angle. Synthesize "
"them into ONE coherent plan/result:\n"
"- Resolve contradictions between angles.\n"
"- Produce a unified, prioritized action plan (or final answer).\n"
"- Call out open risks and the single next step.\n\n"
f"{angle_block}"
)
synthesis, conv_err = _run_angle(converge, synthesis_prompt)
# Persist the run
run_id = uuid.uuid4().hex[:12]
timestamp = get_timestamp()
run_record = {
"run_id": run_id,
"timestamp": timestamp,
"goal": req.goal,
"agents": agents,
"angles": angle_prompts,
"outputs": outputs,
"converge_agent": converge,
"synthesis": synthesis,
"converge_error": conv_err,
}
ORCHESTRATION_DIR.mkdir(parents=True, exist_ok=True)
run_file = ORCHESTRATION_DIR / f"{run_id}.json"
write_json(run_file, run_record)
# Make the run searchable in the brain
try:
bi = _brain_index_module()
conn = bi.get_conn()
bi.upsert_doc(
conn, source="agent-note",
source_path=f"data/orchestration-runs/{run_id}.json",
title=f"Multi-agent run: {req.goal[:80]}",
content=f"GOAL: {req.goal}\n\nSYNTHESIS:\n{synthesis[:2000]}",
agent=f"orchestrate/{converge}", updated_at=timestamp,
)
conn.commit(); conn.close()
except Exception as e:
print(f"[orchestrate] brain upsert failed: {e}")
return {
"status": "completed",
"run_id": run_id,
"goal": req.goal,
"agents": agents,
"outputs": outputs,
"converge_agent": converge,
"synthesis": synthesis,
"converge_error": conv_err,
"run_file": str(run_file.relative_to(BASE_DIR)),
}
@app.get("/api/orchestrate/runs")
def list_orchestration_runs(limit: int = 50):
ORCHESTRATION_DIR.mkdir(parents=True, exist_ok=True)
files = sorted(ORCHESTRATION_DIR.glob("*.json"), reverse=True)[:limit]
runs = []
for f in files:
try:
d = read_json(f, default={})
runs.append({
"run_id": d.get("run_id"),
"timestamp": d.get("timestamp"),
"goal": d.get("goal"),
"agents": d.get("agents"),
"converge_agent": d.get("converge_agent"),
})
except Exception:
continue
return {"runs": runs}
@app.get("/api/skills/{name}/eval")
def get_skill_eval(name: str):
path = resolve_skill_dir(name) / "score-history.json"

View File

@ -0,0 +1,60 @@
---
name: multi-agent-run
description: Spin up multiple agents (opencode, Gemini, Hermes) on the same project goal from different angles, then converge their outputs into one synthesis. Use for parallel multi-perspective problem solving, design, or planning on a shared project.
version: 1.0.0
---
# Multi-Agent Run — Fan-out + Converge
Orchestrate the 3 built-in agents on ONE project goal, each from its own angle,
then merge their outputs into a single coherent result.
## When to use
- A project goal benefits from multiple perspectives at once (implementation +
research + coordination).
- You want a synthesized plan that reconciles engineering, analysis, and ops.
- You want the run persisted and searchable in the brain for later review.
## How it works (backend)
`POST /api/orchestrate`:
1. **Fan-out** — runs each agent in parallel on its angle:
- `opencode` → implementation approach / build plan
- `gemini` → research, analysis, risks, feasibility
- `hermes` → coordination, sequencing, integration, memory
2. **Converge** — feeds all angle outputs to `converge_agent` (default `hermes`)
to produce a unified, prioritized action plan (or final answer).
3. **Persist** — writes `data/orchestration-runs/<run_id>.json` and upserts a
searchable doc into the brain.
## Request shape
```
{
"goal": "Build a real-time sync layer between the dashboard and the brain index",
"angles": { // optional per-agent override
"opencode": "Focus on the websocket protocol and schema migration",
"gemini": "Survey existing real-time sync patterns and their failure modes"
},
"agents": ["opencode", "gemini", "hermes"], // optional override
"converge_agent": "hermes", // optional
"converge_prompt": "..." // optional custom synthesis
}
```
## Response
```
{
"status": "completed",
"run_id": "...",
"outputs": { "opencode": "...", "gemini": "...", "hermes": "..." },
"synthesis": "...", // the converged result
"run_file": "data/orchestration-runs/<run_id>.json"
}
```
## Notes / caveats
- Agents run in parallel, so a run takes ~1 agent-round-trip (not 3x).
- If an angle agent errors, its output is captured as an error string and the
convergence step still proceeds (best-effort, never aborts the whole run).
- Review past runs via `GET /api/orchestrate/runs` or the dashboard Orchestration page.
- The converged synthesis is auto-upserted to the brain (source: agent-note) so
you can later find it with Brain Search.

View File

@ -0,0 +1,66 @@
---
name: multi-angle-orchestration
description: Spin up 5+ parallel subagents (delegate_task leaf workers, all on this same Hermes session — NO external/separate AIs) on one project goal from different angles, then converge. Single-source orchestration for fast deployment.
version: 1.0.0
---
# Multi-Angle Orchestration (single-source, fast)
When the user gives an end goal and says "call up some subagents," this is the
playbook. All work is done by THIS Hermes session's own `delegate_task`
subagents — there is NO opencode / gemini / hermes CLI invocation, no external
agent, no second AI. Fast deployment: parallel fan-out, then converge.
## Hard rules
- **≥5 subagents, always.** Decompose the goal into at least 5 distinct angles.
- **All same source.** Every subagent is a leaf worker on this session's model.
Never call execute_agent / the 3 CLI agents / a separate AI.
- **Parallel.** Launch in one background batch where possible. The tool runs
up to 3 concurrently for this user, so issue 5+ as: first batch of 3, then a
second batch of the remaining (still effectively parallel, fast).
- **No human-in-the-loop inside workers.** Subagents can't clarify; give them
complete context up front.
- **I converge.** When all subagents return, I synthesize their outputs into
one result (no separate synthesizer agent needed unless the goal is huge).
## The flow
1. **Decompose** the goal into ≥5 angles. Good angle variety (pick per goal):
- Implementer (what to build / change)
- Researcher / evidence (facts, refs, prior art)
- Skeptic / risk (failure modes, contradictions, what could break)
- Integrator (how it fits the existing system / repo)
- Communicator / docs (how to explain, UI copy, README, rollout)
- (extra) Tester / QA, Security, Performance, Ops/Deploy
2. **Launch** each angle as a `delegate_task` with FULL context:
- The goal, verbatim.
- Its specific angle + acceptance criteria.
- Relevant repo context (paths, constraints, the established patterns).
- Instruction: return a concise, self-contained result; do not ask questions.
3. **Converge**: reconcile contradictions, produce a unified prioritized plan
or final answer + the single next step.
4. **Log the run**: write `data/orchestration-runs/<run_id>.json` with
{goal, angles, agents:["delegate-x5"], synthesis, timestamp}. Then
`python3 brain-cli.py ingest` so it's searchable in Brain Search (the
orchestration source is indexed). Optionally surface results on the
Orchestration dashboard page.
## delegate_task call shape (per angle)
```
delegate_task(
goal="<angle-specific deliverable>",
context="""GOAL: <full user goal>
ANGLE: <this worker's perspective + acceptance criteria>
CONTEXT: <repo paths, constraints, prior decisions>
OUTPUT: concise, self-contained. No questions.""",
)
```
Batch up to 3 in one `tasks=[...]` call; fire the rest in a second call.
## Notes
- Subagent summaries are self-reports; verify external side-effects (writes,
publishes) yourself before claiming success.
- Keep angle prompts tight — workers get clean isolated context, no shared
state, so everything they need must be in `context`.
- This is the PRIMARY orchestration workflow. The Agentic OS `/api/orchestrate`
backend endpoint (which fans out the 3 CLI agents) is an OPTIONAL headless
alternative only — not used for this single-source workflow.