Skip to content

Commit f39bcfc

Browse files
Zawwarsami16claude
andcommitted
phase 0.9: multi-AI council pattern — test + demo
tests/test_council.py: e2e — 3 panel AIs (alpha/beta/gamma), coordinator publishes a handler that connects to each panel and synthesizes replies, client asks coordinator, asserts synthesized text cites each panel by signature. proves bidirectional substrate supports arbitrary multi-AI orchestration without bespoke routing. examples/council_demo.py: user-facing version of the same pattern with named stubs (claude-stub, gpt-stub, gemini-stub) so the printed output reads like a real council. run alongside zhub.server, see synthesized multi-AI reply. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6eaed73 commit f39bcfc

2 files changed

Lines changed: 219 additions & 0 deletions

File tree

examples/council_demo.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Council demo — three published AIs, one coordinator, all wired through zhub.
2+
3+
Run order:
4+
1. python -m zhub.server --port 8080
5+
2. python examples/council_demo.py
6+
7+
The script:
8+
- publishes 3 stub AIs with distinct voices
9+
- publishes a coordinator AI whose chat-handler queries all 3
10+
- connects a 'user' that talks to the coordinator
11+
- prints the synthesized reply
12+
13+
This is the bidirectional substrate's killer demo: any operator can wire
14+
multiple AIs into a council pattern without bespoke router code.
15+
"""
16+
17+
import asyncio
18+
import logging
19+
20+
from zhub import publish, connect
21+
22+
23+
HUB_URL = "ws://localhost:8080"
24+
25+
26+
def make_panel_handler(signature: str):
27+
def handler(messages, options):
28+
last = messages[-1].get("content", "")
29+
return f"{signature} thinks: {last}"
30+
return handler
31+
32+
33+
async def main():
34+
logging.basicConfig(level=logging.INFO)
35+
36+
# --- 3 panel members ---
37+
panel = []
38+
for name, signature in [
39+
("claude-stub", "[claude]"),
40+
("gpt-stub", "[gpt]"),
41+
("gemini-stub", "[gemini]"),
42+
]:
43+
p = publish(
44+
name=name,
45+
description=f"panel: {name}",
46+
chat_handler=make_panel_handler(signature),
47+
hub_url=HUB_URL,
48+
public=True,
49+
)
50+
panel.append(p)
51+
52+
# Wait for all panel members to register
53+
for p in panel:
54+
while not p.api_key:
55+
await asyncio.sleep(0.1)
56+
print(f"panel registered: {[p.name for p in panel]}")
57+
58+
# --- coordinator ---
59+
panel_creds = [(p.name, p.api_key) for p in panel]
60+
61+
async def coordinator(messages, options):
62+
question = messages[-1].get("content", "")
63+
votes = []
64+
for name, key in panel_creds:
65+
sub = connect(
66+
ai_name=name, api_key=key, hub_url=HUB_URL,
67+
capabilities={},
68+
)
69+
await asyncio.sleep(0.15)
70+
r = await sub.chat(messages=[{"role": "user", "content": question}])
71+
votes.append(r.get("text", ""))
72+
return "Council synthesis:\n " + "\n ".join(votes)
73+
74+
coord = publish(
75+
name="council",
76+
description="multi-AI council coordinator",
77+
chat_handler=coordinator,
78+
hub_url=HUB_URL,
79+
public=True,
80+
)
81+
while not coord.api_key:
82+
await asyncio.sleep(0.1)
83+
print(f"coordinator registered: name={coord.name} key={coord.api_key[:14]}...")
84+
85+
# --- user side ---
86+
user = connect(
87+
ai_name=coord.name, api_key=coord.api_key, hub_url=HUB_URL,
88+
capabilities={},
89+
)
90+
await asyncio.sleep(0.4)
91+
92+
for question in [
93+
"what's the meaning of life?",
94+
"should I deploy on Friday?",
95+
]:
96+
print(f"\n>>> {question}")
97+
resp = await user.chat(messages=[{"role": "user", "content": question}])
98+
print(resp.get("text", "(no text)"))
99+
await asyncio.sleep(0.2)
100+
101+
102+
if __name__ == "__main__":
103+
asyncio.run(main())

tests/test_council.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""End-to-end council pattern — coordinator AI orchestrates 3 panel AIs through the hub."""
2+
3+
import asyncio
4+
import socket
5+
import threading
6+
import time
7+
8+
import pytest
9+
10+
try:
11+
import fastapi # noqa
12+
import uvicorn # noqa
13+
SERVER_AVAILABLE = True
14+
except ImportError:
15+
SERVER_AVAILABLE = False
16+
17+
if SERVER_AVAILABLE:
18+
from zhub.server import create_app
19+
from zhub import publish, connect
20+
21+
22+
def _free_port() -> int:
23+
with socket.socket() as s:
24+
s.bind(("", 0))
25+
return s.getsockname()[1]
26+
27+
28+
@pytest.fixture(scope="module")
29+
def hub_port():
30+
if not SERVER_AVAILABLE:
31+
pytest.skip("fastapi/uvicorn not installed")
32+
port = _free_port()
33+
34+
def run():
35+
config = uvicorn.Config(create_app(), host="127.0.0.1", port=port, log_level="warning")
36+
asyncio.run(uvicorn.Server(config).serve())
37+
38+
threading.Thread(target=run, daemon=True).start()
39+
for _ in range(30):
40+
try:
41+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
42+
break
43+
except OSError:
44+
time.sleep(0.1)
45+
yield port
46+
47+
48+
@pytest.mark.asyncio
49+
async def test_coordinator_calls_all_panel_members(hub_port):
50+
"""The coordinator AI publishes itself, also connects to three other
51+
published AIs, and synthesizes their replies."""
52+
hub_url = f"ws://127.0.0.1:{hub_port}"
53+
54+
# Three "panel" AIs — each returns a distinct signature.
55+
pubs = []
56+
for name, signature in [("alpha", "[A]"), ("beta", "[B]"), ("gamma", "[C]")]:
57+
p = publish(
58+
name=name,
59+
description=f"panel member {name}",
60+
chat_handler=(lambda sig: (lambda m, o: f"{sig} {m[-1]['content']}"))(signature),
61+
hub_url=hub_url,
62+
)
63+
pubs.append(p)
64+
65+
for p in pubs:
66+
for _ in range(50):
67+
if p.api_key:
68+
break
69+
await asyncio.sleep(0.05)
70+
assert p.api_key, f"panel member {p.name} never registered"
71+
72+
# Coordinator: publishes a chat handler that internally connects to each
73+
# panel member and aggregates.
74+
panel_creds = [(p.name, p.api_key) for p in pubs]
75+
76+
async def coordinator_handler(messages, options):
77+
question = messages[-1]["content"]
78+
replies = []
79+
for name, key in panel_creds:
80+
sub = connect(
81+
ai_name=name, api_key=key, hub_url=hub_url,
82+
capabilities={},
83+
)
84+
await asyncio.sleep(0.1)
85+
r = await sub.chat(messages=[{"role": "user", "content": question}])
86+
replies.append(r.get("text", ""))
87+
return "council: " + " | ".join(replies)
88+
89+
coord = publish(
90+
name="coordinator",
91+
description="multi-AI council",
92+
chat_handler=coordinator_handler,
93+
hub_url=hub_url,
94+
)
95+
for _ in range(50):
96+
if coord.api_key:
97+
break
98+
await asyncio.sleep(0.05)
99+
assert coord.api_key, "coordinator never registered"
100+
101+
# Connect a client that asks the coordinator a question.
102+
client = connect(
103+
ai_name=coord.name, api_key=coord.api_key, hub_url=hub_url,
104+
capabilities={},
105+
)
106+
await asyncio.sleep(0.4)
107+
108+
resp = await asyncio.wait_for(
109+
client.chat(messages=[{"role": "user", "content": "ping"}]),
110+
timeout=15.0,
111+
)
112+
text = resp.get("text", "")
113+
assert text.startswith("council:"), f"expected council prefix, got: {text!r}"
114+
assert "[A] ping" in text, f"missing alpha signature: {text!r}"
115+
assert "[B] ping" in text, f"missing beta signature: {text!r}"
116+
assert "[C] ping" in text, f"missing gamma signature: {text!r}"

0 commit comments

Comments
 (0)