From 29eaa7f8016c4a0806d987978f4a477b36a5c08e Mon Sep 17 00:00:00 2001 From: Austin Date: Sat, 25 Jul 2026 18:07:51 -0700 Subject: [PATCH] =?UTF-8?q?feat(orchestration):=20multi-angle=20workflow?= =?UTF-8?q?=20=E2=80=94=205+=20parallel=20subagents,=20converge,=20log=20+?= =?UTF-8?q?=20index=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/.json and upsert to brain. - dashboard/pages/orchestration.js: launch runs + review past runs. - skills/multi-agent-run/SKILL.md + api helpers + nav entry. --- brain-core/brain_index.py | 28 ++++ dashboard/api.js | 3 + dashboard/index.html | 1 + dashboard/pages/orchestration.js | 92 ++++++++++++ dashboard/utils.js | 1 + server.py | 166 ++++++++++++++++++++++ skills/multi-agent-run/SKILL.md | 60 ++++++++ skills/multi-angle-orchestration/SKILL.md | 66 +++++++++ 8 files changed, 417 insertions(+) create mode 100644 dashboard/pages/orchestration.js create mode 100644 skills/multi-agent-run/SKILL.md create mode 100644 skills/multi-angle-orchestration/SKILL.md diff --git a/brain-core/brain_index.py b/brain-core/brain_index.py index 55cd6a4..010b99c 100644 --- a/brain-core/brain_index.py +++ b/brain-core/brain_index.py @@ -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), } diff --git a/dashboard/api.js b/dashboard/api.js index a25f498..bde966b 100644 --- a/dashboard/api.js +++ b/dashboard/api.js @@ -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), diff --git a/dashboard/index.html b/dashboard/index.html index d126e21..0688684 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -51,6 +51,7 @@ πŸ“ŠLearning Analytics 🧠Agent Insights πŸ”Brain Search + πŸ•ΈMulti-Agent Run πŸ”„Session Replay ⏱Agent Time diff --git a/dashboard/pages/orchestration.js b/dashboard/pages/orchestration.js new file mode 100644 index 0000000..0f91e11 --- /dev/null +++ b/dashboard/pages/orchestration.js @@ -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 = ` + +
+
New orchestration run
+ + +
+ Customize angles (optional) +
+ + + + + + +
+
+ +
+
+
Past runs
+
+ `; + await orchLoadRuns(); +} + +async function orchLoadRuns() { + const box = document.getElementById('orchRuns'); + try { + const d = await api.getOrchestrationRuns(30); + if (!d.runs.length) { box.innerHTML = '
No runs yet
'; return; } + box.innerHTML = d.runs.map(r => ` +
+
+ ${escapeHtml(r.goal || '(no goal)')} + ${escapeHtml((r.agents||[]).join(' + '))} + β†’ ${escapeHtml(r.converge_agent||'')} +
+
${escapeHtml(r.run_id||'')} Β· ${escapeHtml(r.timestamp||'')}
+
`).join(''); + } catch (e) { + box.innerHTML = `
Could not load runs
`; + } +} + +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 = '
Fanning out agents & converging…
'; + 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]) => ` +
+
${escapeHtml(a)}angle output
+
${escapeHtml((o||'').slice(0,2000))}
+
`).join(''); + res.innerHTML = ` +
+
βœ… Synthesis (via ${escapeHtml(data.converge_agent||'')})
+
${escapeHtml(data.synthesis || '(no synthesis)')}
+
run_id: ${escapeHtml(data.run_id||'')} Β· saved to ${escapeHtml(data.run_file||'')}
+
+
Show per-agent angle outputs +
${cards}
+
`; + await orchLoadRuns(); + } catch (e) { + res.innerHTML = `
⚠
Orchestration failed
${escapeHtml(e.message)}
`; + } finally { + btn.disabled = false; btn.textContent = 'πŸš€ Launch agents'; + } +} diff --git a/dashboard/utils.js b/dashboard/utils.js index 6a59491..9f27845 100644 --- a/dashboard/utils.js +++ b/dashboard/utils.js @@ -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' }, diff --git a/server.py b/server.py index 2a2b833..463b485 100644 --- a/server.py +++ b/server.py @@ -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" diff --git a/skills/multi-agent-run/SKILL.md b/skills/multi-agent-run/SKILL.md new file mode 100644 index 0000000..9bc3c96 --- /dev/null +++ b/skills/multi-agent-run/SKILL.md @@ -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/.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/.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. diff --git a/skills/multi-angle-orchestration/SKILL.md b/skills/multi-angle-orchestration/SKILL.md new file mode 100644 index 0000000..f76bc49 --- /dev/null +++ b/skills/multi-angle-orchestration/SKILL.md @@ -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/.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="", + context="""GOAL: +ANGLE: +CONTEXT: +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.