Files
hive/core/demos/org_demo.py
T
2026-03-12 09:03:29 -07:00

1377 lines
47 KiB
Python

#!/usr/bin/env python3
"""
Multi-Agent Organization Demo
Demonstrates multiple EventLoopNode agents communicating in arbitrary
directions, simulating a research consultancy organization.
Four agents (Director, Researcher, Analyst, Writer) collaborate via
a send_message tool backed by EventBus + inject_event(). A split-panel
UI shows the chat stream alongside a real-time SVG graph with
active-node glow and message-edge animation.
Usage:
cd /home/timothy/oss/hive/core
python demos/org_demo.py
Then open http://localhost:8767 in your browser.
"""
import asyncio
import json
import logging
import sys
import tempfile
from http import HTTPStatus
from pathlib import Path
import httpx
import websockets
from bs4 import BeautifulSoup
from websockets.http11 import Request, Response
# Add core, tools, and hive root to path
_CORE_DIR = Path(__file__).resolve().parent.parent
_HIVE_DIR = _CORE_DIR.parent
sys.path.insert(0, str(_CORE_DIR))
sys.path.insert(0, str(_HIVE_DIR / "tools" / "src"))
sys.path.insert(0, str(_HIVE_DIR))
import os # noqa: E402
from aden_tools.credentials import CREDENTIAL_SPECS, CredentialStoreAdapter # noqa: E402
from core.framework.credentials import CredentialStore # noqa: E402
from framework.credentials.storage import ( # noqa: E402
CompositeStorage,
EncryptedFileStorage,
EnvVarStorage,
)
from framework.graph.event_loop_node import ( # noqa: E402
EventLoopNode,
JudgeVerdict,
LoopConfig,
)
from framework.graph.node import NodeContext, NodeSpec, SharedMemory # noqa: E402
from framework.llm.litellm import LiteLLMProvider # noqa: E402
from framework.llm.provider import Tool, ToolResult, ToolUse # noqa: E402
from framework.runner.tool_registry import ToolRegistry # noqa: E402
from framework.runtime.core import Runtime # noqa: E402
from framework.runtime.event_bus import ( # noqa: E402
AgentEvent,
EventBus,
EventType,
)
from framework.storage.conversation_store import FileConversationStore # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
logger = logging.getLogger("org_demo")
# -------------------------------------------------------------------------
# Persistent state
# -------------------------------------------------------------------------
STORE_DIR = Path(tempfile.mkdtemp(prefix="hive_org_"))
RUNTIME = Runtime(STORE_DIR / "runtime")
LLM = LiteLLMProvider(model="claude-haiku-4-5-20251001")
# -------------------------------------------------------------------------
# Credentials
# -------------------------------------------------------------------------
_env_mapping = {name: spec.env_var for name, spec in CREDENTIAL_SPECS.items()}
_local_storage = CompositeStorage(
primary=EncryptedFileStorage(),
fallbacks=[EnvVarStorage(env_mapping=_env_mapping)],
)
if os.environ.get("ADEN_API_KEY"):
try:
from framework.credentials.aden import ( # noqa: E402
AdenCachedStorage,
AdenClientConfig,
AdenCredentialClient,
AdenSyncProvider,
)
_client = AdenCredentialClient(AdenClientConfig(base_url="https://api.adenhq.com"))
_provider = AdenSyncProvider(client=_client)
_storage = AdenCachedStorage(
local_storage=_local_storage,
aden_provider=_provider,
)
_cred_store = CredentialStore(storage=_storage, providers=[_provider], auto_refresh=True)
_synced = _provider.sync_all(_cred_store)
logger.info("Synced %d credentials from Aden", _synced)
except Exception as e:
logger.warning("Aden sync unavailable: %s", e)
_cred_store = CredentialStore(storage=_local_storage)
else:
logger.info("ADEN_API_KEY not set, using local credential storage")
_cred_store = CredentialStore(storage=_local_storage)
CREDENTIALS = CredentialStoreAdapter(_cred_store)
# -------------------------------------------------------------------------
# Tool Registry — web_search + web_scrape (for Researcher)
# -------------------------------------------------------------------------
TOOL_REGISTRY = ToolRegistry()
def _exec_web_search(inputs: dict) -> dict:
api_key = CREDENTIALS.get("brave_search")
if not api_key:
return {"error": "brave_search credential not configured"}
query = inputs.get("query", "")
num_results = min(inputs.get("num_results", 5), 20)
resp = httpx.get(
"https://api.search.brave.com/res/v1/web/search",
params={"q": query, "count": num_results},
headers={"X-Subscription-Token": api_key, "Accept": "application/json"},
timeout=30.0,
)
if resp.status_code != 200:
return {"error": f"Brave API HTTP {resp.status_code}"}
data = resp.json()
results = [
{
"title": item.get("title", ""),
"url": item.get("url", ""),
"snippet": item.get("description", ""),
}
for item in data.get("web", {}).get("results", [])[:num_results]
]
return {"query": query, "results": results, "total": len(results)}
TOOL_REGISTRY.register(
name="web_search",
tool=Tool(
name="web_search",
description="Search the web for current information. Returns titles, URLs, and snippets.",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"num_results": {"type": "integer", "description": "Results (1-20, default 5)"},
},
"required": ["query"],
},
),
executor=lambda inputs: _exec_web_search(inputs),
)
_SCRAPE_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml",
}
def _exec_web_scrape(inputs: dict) -> dict:
url = inputs.get("url", "")
max_length = max(1000, min(inputs.get("max_length", 50000), 500000))
if not url.startswith(("http://", "https://")):
url = "https://" + url
try:
resp = httpx.get(url, timeout=30.0, follow_redirects=True, headers=_SCRAPE_HEADERS)
if resp.status_code != 200:
return {"error": f"HTTP {resp.status_code}"}
soup = BeautifulSoup(resp.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header", "aside", "noscript"]):
tag.decompose()
title = soup.title.get_text(strip=True) if soup.title else ""
main = (
soup.find("article")
or soup.find("main")
or soup.find(attrs={"role": "main"})
or soup.find("body")
)
text = main.get_text(separator=" ", strip=True) if main else ""
text = " ".join(text.split())
if len(text) > max_length:
text = text[:max_length] + "..."
return {"url": url, "title": title, "content": text, "length": len(text)}
except httpx.TimeoutException:
return {"error": "Request timed out"}
except Exception as e:
return {"error": f"Scrape failed: {e}"}
TOOL_REGISTRY.register(
name="web_scrape",
tool=Tool(
name="web_scrape",
description="Scrape text content from a webpage URL.",
parameters={
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL to scrape"},
"max_length": {"type": "integer", "description": "Max text length (default 50000)"},
},
"required": ["url"],
},
),
executor=lambda inputs: _exec_web_scrape(inputs),
)
logger.info("Tools loaded: %s", ", ".join(TOOL_REGISTRY.get_registered_names()))
# -------------------------------------------------------------------------
# Node Specs
# -------------------------------------------------------------------------
ROLES = ["director", "researcher", "analyst", "writer"]
ROLE_SPECS = {
"director": NodeSpec(
id="director",
name="Director",
description="Coordinates the team and synthesizes the final report",
node_type="event_loop",
input_keys=["topic"],
output_keys=["final_report"],
system_prompt=(
"You are the Director of a research consultancy team. "
"You receive research topics and coordinate your team.\n\n"
"Your team:\n"
"- researcher: Web research specialist (has web_search/web_scrape)\n"
"- analyst: Data analysis and pattern recognition\n"
"- writer: Technical writer for polished deliverables\n\n"
"Workflow:\n"
"1. Break the topic into specific research tasks\n"
"2. Send tasks to researcher AND analyst via send_message\n"
"3. Wait for their responses (they will message you back)\n"
"4. Send all material to writer for drafting\n"
"5. When writer returns the draft, review it\n"
"6. Call set_output(key='final_report', value=<the report>)\n\n"
"IMPORTANT: Delegate, don't do research or writing yourself."
),
),
"researcher": NodeSpec(
id="researcher",
name="Researcher",
description="Researches topics using web tools",
node_type="event_loop",
system_prompt=(
"You are a Research Specialist. You receive tasks from the "
"team and use web_search and web_scrape to gather info.\n\n"
"When you receive a task:\n"
"1. Search for relevant information (2-3 searches)\n"
"2. Scrape 1-2 promising URLs for detail\n"
"3. Synthesize findings into a clear summary\n"
"4. Send findings back to whoever asked via send_message\n\n"
"Be thorough but efficient. Focus on facts and data."
),
),
"analyst": NodeSpec(
id="analyst",
name="Analyst",
description="Analyzes data and identifies patterns",
node_type="event_loop",
system_prompt=(
"You are a Data Analyst. You receive data and context from "
"team members and provide analytical insights.\n\n"
"When you receive a request:\n"
"1. Analyze the provided information\n"
"2. Identify key themes, patterns, and trends\n"
"3. Assess reliability and significance\n"
"4. Send analysis back via send_message\n\n"
"Be concise but insightful."
),
),
"writer": NodeSpec(
id="writer",
name="Writer",
description="Drafts polished deliverables",
node_type="event_loop",
system_prompt=(
"You are a Technical Writer. You receive research findings "
"and analysis from team members and draft polished reports.\n\n"
"When you receive material:\n"
"1. Organize information into a logical structure\n"
"2. Write a clear, professional report with sections\n"
"3. Include findings, analysis, and recommendations\n"
"4. Send the draft back to director via send_message\n\n"
"Write professionally but accessibly."
),
),
}
def _build_send_tool(role: str) -> Tool:
"""Build a send_message tool with 'to' enum excluding the node itself."""
targets = [r for r in ROLES if r != role]
return Tool(
name="send_message",
description=(
"Send a message to another team member. "
"Use this to delegate tasks, share findings, or return work."
),
parameters={
"type": "object",
"properties": {
"to": {
"type": "string",
"enum": targets,
"description": f"Team member: {', '.join(targets)}",
},
"message": {
"type": "string",
"description": "The message content",
},
},
"required": ["to", "message"],
},
)
# Per-role tool lists
_web_tools = list(TOOL_REGISTRY.get_tools().values())
ROLE_TOOLS: dict[str, list[Tool]] = {}
for _role in ROLES:
_tools = [_build_send_tool(_role)]
if _role == "researcher":
_tools = _web_tools + _tools
ROLE_TOOLS[_role] = _tools
# -------------------------------------------------------------------------
# OrgJudge — blocks between messages, manages node lifecycle
# -------------------------------------------------------------------------
class OrgJudge:
"""Judge for org demo nodes.
- Director: blocks until message arrives, ACCEPTs when output_keys filled
- Specialists: block until message arrives, ACCEPT on done signal
"""
# Director gets a longer window (waiting for multiple specialist
# replies); specialists only need to wait for follow-ups.
_DIRECTOR_TIMEOUT = 120
_SPECIALIST_TIMEOUT = 30
def __init__(
self,
is_director: bool = False,
bus: EventBus | None = None,
node_id: str = "",
):
self._is_director = is_director
self._message_queue: asyncio.Queue = asyncio.Queue()
self._done = asyncio.Event()
self._bus = bus
self._node_id = node_id
self._timeout = self._DIRECTOR_TIMEOUT if is_director else self._SPECIALIST_TIMEOUT
async def evaluate(self, context: dict) -> JudgeVerdict:
if self._done.is_set():
return JudgeVerdict(action="ACCEPT")
# Director: accept when final_report is set
if self._is_director:
missing = context.get("missing_keys", [])
if not missing:
return JudgeVerdict(action="ACCEPT")
# Signal UI that this node is waiting for a message
if self._bus:
await self._bus.publish(
AgentEvent(
type=EventType.CUSTOM,
stream_id="org",
node_id=self._node_id,
data={"custom_type": "node_waiting", "node_id": self._node_id},
)
)
# Block until next message or done
try:
await asyncio.wait_for(self._wait_signal(), timeout=self._timeout)
except TimeoutError:
logger.info("OrgJudge %s idle timeout (%ds)", self._node_id, self._timeout)
return JudgeVerdict(action="ACCEPT")
if self._done.is_set():
return JudgeVerdict(action="ACCEPT")
return JudgeVerdict(action="RETRY")
async def _wait_signal(self):
"""Wait for either a message or done signal."""
msg_task = asyncio.create_task(self._message_queue.get())
done_task = asyncio.create_task(self._done.wait())
try:
_done, pending = await asyncio.wait(
{msg_task, done_task},
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
except Exception:
msg_task.cancel()
done_task.cancel()
def signal_message(self):
"""Signal that a new message has been injected."""
self._message_queue.put_nowait(True)
def signal_done(self):
"""Signal global shutdown."""
self._done.set()
try:
self._message_queue.put_nowait(None)
except asyncio.QueueFull:
pass
# -------------------------------------------------------------------------
# MessageRouter — routes inter-node messages with lazy start
# -------------------------------------------------------------------------
class MessageRouter:
"""Routes messages between nodes via inject_event + judge signaling."""
def __init__(self, bus: EventBus):
self._bus = bus
self._nodes: dict[str, EventLoopNode] = {}
self._judges: dict[str, OrgJudge] = {}
self._contexts: dict[str, NodeContext] = {}
self._tasks: dict[str, asyncio.Task] = {}
def register(
self,
role: str,
node: EventLoopNode,
judge: OrgJudge,
context: NodeContext,
):
self._nodes[role] = node
self._judges[role] = judge
self._contexts[role] = context
def start(self, role: str):
"""Start a node's event loop as a background task."""
if role not in self._tasks:
self._tasks[role] = asyncio.create_task(self._nodes[role].execute(self._contexts[role]))
logger.info(f"Started node: {role}")
async def send(self, from_id: str, to_id: str, message: str):
"""Send a message from one node to another (lazy start)."""
if to_id not in self._nodes:
raise ValueError(f"Unknown target node: {to_id}")
# Lazy start the target node if not running
first_start = to_id not in self._tasks
if first_start:
self.start(to_id)
await self._bus.publish(
AgentEvent(
type=EventType.CUSTOM,
stream_id="org",
node_id=to_id,
data={"custom_type": "node_started", "node_id": to_id},
)
)
# Inject message into target's queue
formatted = f"[Message from {from_id}]: {message}"
await self._nodes[to_id].inject_event(formatted)
# Only signal existing nodes — newly started nodes will drain the
# injection queue on their first iteration, so the signal would be
# stale by the time the judge sees it (causing a spurious RETRY
# that leads to an LLM call with no new content → empty stream).
if not first_start:
self._judges[to_id].signal_message()
logger.info(f"Message: {from_id} -> {to_id} ({len(message)} chars)")
# Emit event for UI edge animation
await self._bus.publish(
AgentEvent(
type=EventType.CUSTOM,
stream_id="org",
data={
"custom_type": "message_sent",
"from": from_id,
"to": to_id,
"preview": message[:150],
},
)
)
def shutdown_all(self):
"""Signal all judges to accept and shut down."""
for judge in self._judges.values():
judge.signal_done()
async def wait_all(self, exclude: str = "", timeout: float = 10.0):
"""Wait for all running tasks (except exclude) to finish."""
remaining = [t for r, t in self._tasks.items() if r != exclude and not t.done()]
if remaining:
_done, pending = await asyncio.wait(remaining, timeout=timeout)
for t in pending:
t.cancel()
def total_tokens(self) -> int:
"""Sum tokens across all completed tasks."""
total = 0
for t in self._tasks.values():
if t.done() and not t.cancelled():
try:
total += t.result().tokens_used or 0
except Exception:
pass
return total
# -------------------------------------------------------------------------
# Tool executor factory
# -------------------------------------------------------------------------
def _recover_send_args(raw: str) -> tuple[str, str]:
"""Try to extract 'to' and 'message' from a malformed JSON string.
When the LLM produces a very long message value with unescaped
characters, json.loads fails and we get {"_raw": "..."}. Regex
extraction is a best-effort fallback.
"""
import re
to = ""
message = ""
to_match = re.search(r'"to"\s*:\s*"(\w+)"', raw)
if to_match:
to = to_match.group(1)
# message is typically the longest field; grab everything after the key
msg_match = re.search(r'"message"\s*:\s*"', raw)
if msg_match:
# Take from after the opening quote to the end, strip trailing "}
start = msg_match.end()
message = raw[start:].rstrip()
# Strip trailing close-quote + brace(s) if present
for suffix in ('"}\n', '"}', '"'):
if message.endswith(suffix):
message = message[: -len(suffix)]
break
return to, message
def make_executor(role: str, router: MessageRouter, base_executor):
"""Build a tool executor that handles send_message + delegates rest."""
async def _send_message(tool_use: ToolUse) -> ToolResult:
to = tool_use.input.get("to", "")
message = tool_use.input.get("message", "")
# Recover from malformed JSON (long messages break json.loads)
if not to and "_raw" in tool_use.input:
raw = tool_use.input["_raw"]
to, message = _recover_send_args(raw)
if to:
logger.info("Recovered send_message args from raw string: to=%s", to)
if to == role:
return ToolResult(
tool_use_id=tool_use.id,
content="Cannot send message to yourself.",
is_error=True,
)
if to not in router._nodes:
valid = [r for r in ROLES if r != role]
return ToolResult(
tool_use_id=tool_use.id,
content=(
f"Unknown team member: '{to}'. "
f"Valid targets: {', '.join(valid)}. "
f"Use send_message with {{'to': '<name>', 'message': '<text>'}}."
),
is_error=True,
)
await router.send(role, to, message)
return ToolResult(
tool_use_id=tool_use.id,
content=f"Message delivered to {to}.",
)
def executor(tool_use: ToolUse):
if tool_use.name == "send_message":
return _send_message(tool_use) # coroutine, awaited by EventLoopNode
return base_executor(tool_use)
return executor
# -------------------------------------------------------------------------
# HTML page (embedded)
# -------------------------------------------------------------------------
HTML_PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Multi-Agent Org Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'SF Mono', 'Fira Code', monospace;
background: #0d1117; color: #c9d1d9;
height: 100vh; display: flex; flex-direction: column;
}
header {
background: #161b22; padding: 10px 20px;
border-bottom: 1px solid #30363d;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
}
header h1 { font-size: 15px; color: #58a6ff; font-weight: 600; }
.badge {
font-size: 11px; padding: 2px 8px; border-radius: 10px;
background: #21262d; color: #484f58;
}
.badge.active { font-weight: 600; }
.badge.director.active { background: #1a3a5c; color: #58a6ff; }
.badge.researcher.active { background: #3d2b00; color: #d29922; }
.badge.analyst.active { background: #1a4b2e; color: #3fb950; }
.badge.writer.active { background: #2d1a4b; color: #bc8cff; }
.badge.done { background: #1a4b2e; color: #3fb950; }
.badge.waiting { background: #1c1c1c; color: #6e7681; }
.main {
flex: 1; display: flex; overflow: hidden;
}
.chat {
flex: 65; overflow-y: auto; padding: 12px; min-width: 0;
border-right: 1px solid #30363d;
}
.graph-panel {
flex: 35; display: flex; flex-direction: column;
padding: 12px; min-width: 280px; background: #0d1117;
}
.graph-title {
font-size: 12px; color: #8b949e; font-weight: 600;
margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px;
}
.graph-svg { flex: 1; width: 100%; }
.graph-legend {
font-size: 10px; color: #484f58; margin-top: 8px;
line-height: 1.6;
}
.legend-dot {
display: inline-block; width: 8px; height: 8px;
border-radius: 50%; margin-right: 4px; vertical-align: middle;
}
.msg {
margin: 4px 0; padding: 8px 12px; border-radius: 6px;
line-height: 1.5; white-space: pre-wrap; word-wrap: break-word;
font-size: 13px; border-left: 3px solid transparent;
}
.msg.user { background: #1a3a5c; color: #58a6ff; border-left-color: #58a6ff; }
.msg.assistant { background: #161b22; color: #c9d1d9; }
.msg.event {
background: transparent; color: #8b949e; font-size: 11px;
padding: 3px 12px;
}
.msg.event.tool { border-left-color: #d29922; }
.msg.event.stall { border-left-color: #f85149; }
.msg.event.msg-sent { border-left-color: #58a6ff; color: #58a6ff; }
.msg.event.lifecycle { color: #6e7681; font-style: italic; }
.msg.event.done { color: #3fb950; }
.msg.msg-output {
background: #131820; font-size: 12px; color: #8b949e;
max-height: 220px; overflow-y: auto; border-left-width: 2px;
}
.msg .node-tag {
font-size: 10px; font-weight: 700; margin-right: 6px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.node-director { border-left-color: #58a6ff; }
.node-director .node-tag { color: #58a6ff; }
.node-researcher { border-left-color: #d29922; }
.node-researcher .node-tag { color: #d29922; }
.node-analyst { border-left-color: #3fb950; }
.node-analyst .node-tag { color: #3fb950; }
.node-writer { border-left-color: #bc8cff; }
.node-writer .node-tag { color: #bc8cff; }
.result-banner {
margin: 12px 0; padding: 14px; border-radius: 8px;
background: #0a2614; border: 1px solid #3fb950;
}
.result-banner h3 {
color: #3fb950; font-size: 13px; margin-bottom: 8px; text-align: center;
}
.result-banner .report {
color: #c9d1d9; font-size: 12px; line-height: 1.6;
max-height: 400px; overflow-y: auto; white-space: pre-wrap;
}
.result-banner .tokens {
color: #484f58; font-size: 10px; text-align: center; margin-top: 8px;
}
.input-bar {
padding: 10px 16px; background: #161b22;
border-top: 1px solid #30363d; display: flex; gap: 8px;
}
.input-bar input {
flex: 1; background: #0d1117; border: 1px solid #30363d;
color: #c9d1d9; padding: 8px 12px; border-radius: 6px;
font-family: inherit; font-size: 13px; outline: none;
}
.input-bar input:focus { border-color: #58a6ff; }
.input-bar button {
background: #238636; color: #fff; border: none;
padding: 8px 18px; border-radius: 6px; cursor: pointer;
font-family: inherit; font-weight: 600; font-size: 13px;
}
.input-bar button:hover { background: #2ea043; }
.input-bar button:disabled {
background: #21262d; color: #484f58; cursor: not-allowed;
}
/* SVG graph styles */
.graph-node rect {
transition: stroke-width 0.2s, stroke 0.2s;
}
.graph-node.active rect { stroke-width: 3; }
#gnode-director.active rect { stroke: #58a6ff; }
#gnode-researcher.active rect { stroke: #d29922; }
#gnode-analyst.active rect { stroke: #3fb950; }
#gnode-writer.active rect { stroke: #bc8cff; }
#gnode-director.done rect { stroke: #58a6ff; stroke-width: 2; }
#gnode-researcher.done rect { stroke: #d29922; stroke-width: 2; }
#gnode-analyst.done rect { stroke: #3fb950; stroke-width: 2; }
#gnode-writer.done rect { stroke: #bc8cff; stroke-width: 2; }
@keyframes edgePulse {
0% { stroke-opacity: 1; stroke-width: 3; }
100% { stroke-opacity: 0.3; stroke-width: 1.5; }
}
svg line.flash, svg path.flash {
stroke: #58a6ff !important;
animation: edgePulse 0.8s ease-out forwards;
}
/* Waiting state — marching-ants dashed border */
.graph-node.waiting rect {
stroke: #484f58; stroke-width: 2;
stroke-dasharray: 8 4;
animation: waitingDash 1.2s linear infinite;
}
@keyframes waitingDash {
to { stroke-dashoffset: -24; }
}
/* Badge spinner */
.badge.waiting::before {
content: ''; display: inline-block;
width: 8px; height: 8px;
border: 1.5px solid #30363d; border-top-color: #6e7681;
border-radius: 50%; vertical-align: middle; margin-right: 4px;
animation: badgeSpin 0.7s linear infinite;
}
@keyframes badgeSpin {
to { transform: rotate(360deg); }
}
@keyframes nodeGlow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0; }
}
</style>
</head>
<body>
<header>
<h1>Multi-Agent Org</h1>
<span id="badge-director" class="badge director">Director</span>
<span id="badge-researcher" class="badge researcher">Researcher</span>
<span id="badge-analyst" class="badge analyst">Analyst</span>
<span id="badge-writer" class="badge writer">Writer</span>
<span id="badge-status" class="badge">Idle</span>
</header>
<div class="main">
<div id="chat" class="chat"></div>
<div class="graph-panel">
<div class="graph-title">Organization Graph</div>
<svg class="graph-svg" viewBox="0 0 440 240" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arrow" markerWidth="8" markerHeight="6"
refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#484f58"/>
</marker>
</defs>
<!-- Edges -->
<line id="edge-director-researcher"
x1="220" y1="64" x2="75" y2="148"
stroke="#21262d" stroke-width="1.5" marker-end="url(#arrow)"/>
<line id="edge-director-analyst"
x1="220" y1="64" x2="220" y2="148"
stroke="#21262d" stroke-width="1.5" marker-end="url(#arrow)"/>
<line id="edge-director-writer"
x1="220" y1="64" x2="365" y2="148"
stroke="#21262d" stroke-width="1.5" marker-end="url(#arrow)"/>
<path id="edge-analyst-researcher"
d="M 130 172 Q 147 200 165 172"
stroke="#21262d" stroke-width="1" fill="none"
stroke-dasharray="4"/>
<!-- Director -->
<g id="gnode-director" class="graph-node">
<rect x="160" y="20" width="120" height="44" rx="8"
fill="#161b22" stroke="#30363d" stroke-width="2"/>
<text x="220" y="47" fill="#c9d1d9" text-anchor="middle"
font-size="12" font-weight="600"
font-family="SF Mono, Fira Code, monospace">Director</text>
</g>
<text id="status-director" x="220" y="80" fill="#484f58"
text-anchor="middle" font-size="9"
font-family="SF Mono, Fira Code, monospace">idle</text>
<!-- Researcher -->
<g id="gnode-researcher" class="graph-node">
<rect x="12" y="150" width="116" height="44" rx="8"
fill="#161b22" stroke="#30363d" stroke-width="2"/>
<text x="70" y="177" fill="#c9d1d9" text-anchor="middle"
font-size="11" font-weight="600"
font-family="SF Mono, Fira Code, monospace">Researcher</text>
</g>
<text id="status-researcher" x="70" y="210" fill="#484f58"
text-anchor="middle" font-size="9"
font-family="SF Mono, Fira Code, monospace">idle</text>
<!-- Analyst -->
<g id="gnode-analyst" class="graph-node">
<rect x="162" y="150" width="116" height="44" rx="8"
fill="#161b22" stroke="#30363d" stroke-width="2"/>
<text x="220" y="177" fill="#c9d1d9" text-anchor="middle"
font-size="12" font-weight="600"
font-family="SF Mono, Fira Code, monospace">Analyst</text>
</g>
<text id="status-analyst" x="220" y="210" fill="#484f58"
text-anchor="middle" font-size="9"
font-family="SF Mono, Fira Code, monospace">idle</text>
<!-- Writer -->
<g id="gnode-writer" class="graph-node">
<rect x="312" y="150" width="116" height="44" rx="8"
fill="#161b22" stroke="#30363d" stroke-width="2"/>
<text x="370" y="177" fill="#c9d1d9" text-anchor="middle"
font-size="12" font-weight="600"
font-family="SF Mono, Fira Code, monospace">Writer</text>
</g>
<text id="status-writer" x="370" y="210" fill="#484f58"
text-anchor="middle" font-size="9"
font-family="SF Mono, Fira Code, monospace">idle</text>
</svg>
<div class="graph-legend">
<span class="legend-dot" style="background:#58a6ff"></span>Director
<span class="legend-dot" style="background:#d29922;margin-left:8px"></span>Researcher
<span class="legend-dot" style="background:#3fb950;margin-left:8px"></span>Analyst
<span class="legend-dot" style="background:#bc8cff;margin-left:8px"></span>Writer
</div>
</div>
</div>
<div class="input-bar">
<input id="input" type="text"
placeholder="Enter a research topic..." autofocus />
<button id="go" onclick="run()">Start</button>
</div>
<script>
const chat = document.getElementById('chat');
const goBtn = document.getElementById('go');
const inputEl = document.getElementById('input');
const statusBadge = document.getElementById('badge-status');
const nodeColors = {
director: '#58a6ff', researcher: '#d29922',
analyst: '#3fb950', writer: '#bc8cff'
};
const nodeLabels = {
director: 'Director', researcher: 'Researcher',
analyst: 'Analyst', writer: 'Writer'
};
let ws = null;
const assistantEls = {};
const nodeTimers = {};
inputEl.addEventListener('keydown', e => { if (e.key === 'Enter') run(); });
function setStatus(text, cls) {
statusBadge.textContent = text;
statusBadge.className = 'badge ' + (cls || '');
}
function setNodeActive(nodeId, active) {
const g = document.getElementById('gnode-' + nodeId);
if (g) {
if (active) { g.classList.add('active'); g.classList.remove('done','waiting'); }
else g.classList.remove('active');
}
const b = document.getElementById('badge-' + nodeId);
if (b) {
b.classList.toggle('active', active);
if (active) b.classList.remove('waiting', 'done');
}
}
function setNodeDone(nodeId) {
const g = document.getElementById('gnode-' + nodeId);
if (g) { g.classList.remove('active','waiting'); g.classList.add('done'); }
setNodeStatus(nodeId, 'done');
const b = document.getElementById('badge-' + nodeId);
if (b) { b.classList.remove('active','waiting'); b.classList.add('done'); }
}
const spinChars = ['\u280b','\u2819','\u2839','\u2838','\u283c',
'\u2834','\u2826','\u2827','\u2807','\u280f'];
const spinTimers = {};
function setNodeStatus(nodeId, text) {
const s = document.getElementById('status-' + nodeId);
if (!s) return;
// Clear any running spinner
if (spinTimers[nodeId]) { clearInterval(spinTimers[nodeId]); delete spinTimers[nodeId]; }
if (text === 'waiting') {
let f = 0;
s.textContent = spinChars[0] + ' waiting';
spinTimers[nodeId] = setInterval(() => {
f = (f + 1) % spinChars.length;
s.textContent = spinChars[f] + ' waiting';
}, 80);
} else {
s.textContent = text;
}
}
function flashEdge(from, to) {
const id = 'edge-' + [from, to].sort().join('-');
const el = document.getElementById(id);
if (!el) return;
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
setTimeout(() => el.classList.remove('flash'), 900);
}
function activateNode(nodeId, status) {
setNodeActive(nodeId, true);
setNodeStatus(nodeId, status || 'thinking');
// Clear any idle timer — the node is explicitly active
clearTimeout(nodeTimers[nodeId]);
}
function addMsg(html, cls) {
const el = document.createElement('div');
el.className = 'msg ' + cls;
el.innerHTML = html;
chat.appendChild(el);
chat.scrollTop = chat.scrollHeight;
return el;
}
function addNodeMsg(nodeId, text, cls) {
const tag = '<span class="node-tag">' + (nodeLabels[nodeId]||nodeId) + '</span>';
const el = addMsg(tag, 'assistant node-' + nodeId + ' ' + (cls||''));
const span = document.createElement('span');
span.className = 'text-content';
span.textContent = text;
el.appendChild(span);
return el;
}
function addEventMsg(nodeId, text, cls) {
const prefix = nodeId ? ('[' + (nodeLabels[nodeId]||nodeId) + '] ') : '';
return addMsg(prefix + text, 'event node-' + (nodeId||'system') + ' ' + (cls||''));
}
function connect() {
ws = new WebSocket('ws://' + location.host + '/ws');
ws.onopen = () => { setStatus('Ready', 'done'); goBtn.disabled = false; };
ws.onmessage = handleEvent;
ws.onerror = () => setStatus('Error', 'error');
ws.onclose = () => {
setStatus('Reconnecting...', '');
goBtn.disabled = true;
setTimeout(connect, 2000);
};
}
function handleEvent(msg) {
const evt = JSON.parse(msg.data);
const nid = evt.node_id || '';
// --- Node lifecycle ---
if (evt.type === 'node_loop_started') {
activateNode(nid, 'starting');
addEventMsg(nid, 'joined the team', 'lifecycle');
}
else if (evt.type === 'node_loop_iteration') {
activateNode(nid, 'thinking');
}
else if (evt.type === 'node_loop_completed') {
// Clean up any empty trailing assistant bubble
if (assistantEls[nid]) {
var tc = assistantEls[nid].querySelector('.text-content');
if (tc && !tc.textContent) assistantEls[nid].remove();
assistantEls[nid] = null;
}
setNodeDone(nid);
var iters = evt.iterations || '?';
addEventMsg(nid, 'finished (' + iters + ' iterations)', 'lifecycle done');
}
else if (evt.type === 'node_started') {
// Custom event from lazy start
activateNode(evt.node_id, 'starting');
}
else if (evt.type === 'node_waiting') {
setNodeActive(nid, false);
setNodeStatus(nid, 'waiting');
var g = document.getElementById('gnode-' + nid);
if (g) { g.classList.add('waiting'); }
var b = document.getElementById('badge-' + nid);
if (b) { b.classList.remove('active','done'); b.classList.add('waiting'); }
}
else if (evt.type === 'node_compaction') {
var pct = (evt.usage_before || '?') + '% \u2192 ' + (evt.usage_after || '?') + '%';
addEventMsg(nid, 'context compacted (' + evt.level + ', ' + pct + ')', 'lifecycle');
}
// --- LLM streaming ---
else if (evt.type === 'llm_text_delta') {
activateNode(nid, 'streaming');
if (!assistantEls[nid]) {
assistantEls[nid] = addNodeMsg(nid, '');
}
const tc = assistantEls[nid].querySelector('.text-content');
if (tc) tc.textContent += evt.content;
chat.scrollTop = chat.scrollHeight;
}
// --- Tool calls ---
else if (evt.type === 'tool_call_started') {
activateNode(nid, evt.tool_name);
if (assistantEls[nid]) {
const tc = assistantEls[nid].querySelector('.text-content');
if (tc && !tc.textContent) assistantEls[nid].remove();
assistantEls[nid] = null;
}
if (evt.tool_name === 'send_message') {
var target = '';
var msgBody = '';
try { target = evt.tool_input.to || ''; } catch(e) {}
try { msgBody = evt.tool_input.message || ''; } catch(e) {}
addEventMsg(nid, '\u2192 ' + (nodeLabels[target] || target), 'msg-sent');
if (msgBody) {
var preview = msgBody.length > 600 ? msgBody.slice(0, 600) + '\u2026' : msgBody;
addNodeMsg(nid, preview, 'msg-output');
}
} else {
var info = evt.tool_name + '(' + JSON.stringify(evt.tool_input).slice(0,100) + ')';
addEventMsg(nid, 'TOOL ' + info, 'tool');
}
}
else if (evt.type === 'tool_call_completed') {
if (evt.tool_name !== 'send_message') {
var preview = (evt.result || '').slice(0, 200);
var cls = evt.is_error ? 'stall' : 'tool';
addEventMsg(nid, 'RESULT ' + evt.tool_name + ': ' + preview, cls);
}
activateNode(nid, 'thinking');
assistantEls[nid] = addNodeMsg(nid, '');
}
// --- Inter-node messages ---
else if (evt.type === 'message_sent') {
flashEdge(evt.from, evt.to);
}
// --- Errors & stalls ---
else if (evt.type === 'node_stalled') {
addEventMsg(nid, 'STALLED: ' + (evt.reason || ''), 'stall');
}
// --- Pipeline done ---
else if (evt.type === 'org_done') {
setStatus('Done', 'done');
for (var r in nodeLabels) {
setNodeDone(r);
clearTimeout(nodeTimers[r]);
}
// Clean up empty assistant els
for (var k in assistantEls) {
if (assistantEls[k]) {
var tc = assistantEls[k].querySelector('.text-content');
if (tc && !tc.textContent) assistantEls[k].remove();
assistantEls[k] = null;
}
}
// Result banner
var banner = document.createElement('div');
banner.className = 'result-banner';
var h3 = document.createElement('h3');
h3.textContent = 'Pipeline Complete';
banner.appendChild(h3);
if (evt.final_report) {
var report = document.createElement('div');
report.className = 'report';
report.textContent = typeof evt.final_report === 'string'
? evt.final_report
: JSON.stringify(evt.final_report, null, 2);
banner.appendChild(report);
}
if (evt.total_tokens) {
var tok = document.createElement('div');
tok.className = 'tokens';
tok.textContent = 'Total tokens: ' + evt.total_tokens.toLocaleString();
banner.appendChild(tok);
}
chat.appendChild(banner);
chat.scrollTop = chat.scrollHeight;
goBtn.disabled = false;
inputEl.placeholder = 'Enter another topic...';
}
else if (evt.type === 'error') {
setStatus('Error', 'error');
addMsg('ERROR: ' + (evt.message || ''), 'event stall');
goBtn.disabled = false;
}
}
function run() {
const text = inputEl.value.trim();
if (!text || !ws || ws.readyState !== 1) return;
chat.innerHTML = '';
// Reset graph
for (var r in nodeLabels) {
var g = document.getElementById('gnode-' + r);
if (g) { g.classList.remove('active','done'); }
var s = document.getElementById('status-' + r);
if (s) s.textContent = 'idle';
var b = document.getElementById('badge-' + r);
if (b) { b.classList.remove('active','done','waiting'); }
assistantEls[r] = null;
}
addMsg(text, 'user');
setStatus('Running', 'active');
goBtn.disabled = true;
inputEl.value = '';
ws.send(JSON.stringify({ topic: text }));
}
connect();
</script>
</body>
</html>"""
# -------------------------------------------------------------------------
# WebSocket handler — org pipeline
# -------------------------------------------------------------------------
async def handle_ws(websocket):
"""Handle WebSocket connections for the org demo."""
try:
async for raw in websocket:
try:
msg = json.loads(raw)
except Exception:
continue
topic = msg.get("topic", "")
if not topic:
continue
logger.info(f"Starting org pipeline for: {topic}")
try:
await _run_org_pipeline(websocket, topic)
except websockets.exceptions.ConnectionClosed:
logger.info("WebSocket closed during pipeline")
return
except Exception as e:
logger.exception("Pipeline error")
try:
await websocket.send(json.dumps({"type": "error", "message": str(e)}))
except Exception:
pass
except websockets.exceptions.ConnectionClosed:
pass
async def _run_org_pipeline(websocket, topic: str):
"""Execute the multi-agent org pipeline."""
import shutil
run_dir = Path(tempfile.mkdtemp(prefix="hive_run_", dir=STORE_DIR))
bus = EventBus()
# Forward bus events to WebSocket
async def forward_event(event):
try:
payload = {"type": event.type.value, **event.data}
if event.node_id:
payload["node_id"] = event.node_id
# Remap CUSTOM events to their custom_type
if event.type == EventType.CUSTOM and "custom_type" in event.data:
payload["type"] = event.data["custom_type"]
await websocket.send(json.dumps(payload))
except Exception:
pass
bus.subscribe(
event_types=[
EventType.NODE_LOOP_STARTED,
EventType.NODE_LOOP_ITERATION,
EventType.NODE_LOOP_COMPLETED,
EventType.LLM_TEXT_DELTA,
EventType.TOOL_CALL_STARTED,
EventType.TOOL_CALL_COMPLETED,
EventType.NODE_STALLED,
EventType.CUSTOM,
],
handler=forward_event,
)
# Build router with all nodes
router = MessageRouter(bus=bus)
base_executor = TOOL_REGISTRY.get_executor()
for role in ROLES:
store = FileConversationStore(run_dir / role)
judge = OrgJudge(
is_director=(role == "director"),
bus=bus,
node_id=role,
)
executor = make_executor(role, router, base_executor)
node = EventLoopNode(
event_bus=bus,
judge=judge,
config=LoopConfig(
max_iterations=30,
max_tool_calls_per_turn=30,
max_context_tokens=32_000,
),
conversation_store=store,
tool_executor=executor,
)
input_data = {"topic": topic} if role == "director" else {}
ctx = NodeContext(
runtime=RUNTIME,
node_id=role,
node_spec=ROLE_SPECS[role],
memory=SharedMemory(),
input_data=input_data,
llm=LLM,
available_tools=ROLE_TOOLS[role],
max_tokens=64000,
)
router.register(role, node, judge, ctx)
# Start director (specialists start lazily via MessageRouter.send)
router.start("director")
# Wait for director to complete (with global timeout)
try:
director_result = await asyncio.wait_for(
router._tasks["director"],
timeout=600,
)
except TimeoutError:
router.shutdown_all()
await router.wait_all(timeout=5.0)
msg = {"type": "error", "message": "Pipeline timed out (10 min)"}
await websocket.send(json.dumps(msg))
shutil.rmtree(run_dir, ignore_errors=True)
return
logger.info(
"Director done: success=%s, tokens=%s",
director_result.success,
director_result.tokens_used,
)
# Shut down all specialist nodes
router.shutdown_all()
await router.wait_all(exclude="director", timeout=10.0)
total_tokens = router.total_tokens()
# Extract final report
final_report = director_result.output.get("final_report", "")
if not final_report and director_result.output:
final_report = json.dumps(director_result.output, indent=2)
# Send result to browser
if director_result.success:
await websocket.send(
json.dumps(
{
"type": "org_done",
"final_report": final_report,
"total_tokens": total_tokens,
}
)
)
else:
await websocket.send(
json.dumps(
{
"type": "error",
"message": f"Director failed: {director_result.error}",
}
)
)
# Clean up
shutil.rmtree(run_dir, ignore_errors=True)
# -------------------------------------------------------------------------
# HTTP handler
# -------------------------------------------------------------------------
async def process_request(connection, request: Request):
"""Serve HTML on GET /, upgrade to WebSocket on /ws."""
if request.path == "/ws":
return None
return Response(
HTTPStatus.OK,
"OK",
websockets.Headers({"Content-Type": "text/html; charset=utf-8"}),
HTML_PAGE.encode(),
)
# -------------------------------------------------------------------------
# Main
# -------------------------------------------------------------------------
async def main():
port = 8767
async with websockets.serve(
handle_ws,
"0.0.0.0",
port,
process_request=process_request,
):
logger.info(f"Org demo running at http://localhost:{port}")
logger.info("Open in your browser and enter a research topic.")
await asyncio.Future()
if __name__ == "__main__":
asyncio.run(main())