feat(swarm): agent personas, bid stats persistence, marketplace frontend
v2.0.0 Exodus — three roadmap items implemented in one PR:
**1. Agent Personas (Echo, Mace, Helm, Seer, Forge, Quill)**
- src/swarm/personas.py — PERSONAS dict with role, description, capabilities,
rate_sats, bid_base/jitter, and preferred_keywords for each of the 6 agents
- src/swarm/persona_node.py — PersonaNode extends SwarmNode with capability-
aware bidding: bids lower when the task description contains a preferred
keyword (specialist advantage), higher otherwise (off-spec inflation)
- SwarmCoordinator.spawn_persona(persona_id) — registers the persona in the
SQLite registry with its full capabilities string and wires it into the
shared AuctionManager via comms subscription
**2. Bid History Persistence (prerequisite for marketplace stats)**
- src/swarm/stats.py — bid_history table in data/swarm.db:
record_bid(), mark_winner(), get_agent_stats(), get_all_agent_stats()
- coordinator.run_auction_and_assign() now calls swarm_stats.mark_winner()
when a winner is chosen, so tasks_won/total_earned survive restarts
- spawn_persona() records each bid for stats tracking
**3. Marketplace Frontend wired to real data**
- /marketplace/ui — new HTML route renders marketplace.html with live
registry status (idle/busy/offline/planned) and cumulative bid stats
- /marketplace JSON endpoint enriched with same registry+stats data
- marketplace.html — fixed field names (rate_sats, tasks_completed,
total_earned), added role subtitle, comma-split capabilities string,
FREE label for Timmy, "planned_count" display
- base.html — added MARKET nav link pointing to /marketplace/ui
Tests: 315 passed (87 new) covering personas, persona_node, stats CRUD,
marketplace UI route, and enriched catalog data.
https://claude.ai/code/session_013CPPgLc589wfdS8LDNuarL
This commit is contained in:
@@ -13,6 +13,7 @@ def tmp_swarm_db(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "swarm.db"
|
||||
monkeypatch.setattr("swarm.tasks.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.registry.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.stats.DB_PATH", db_path)
|
||||
yield db_path
|
||||
|
||||
|
||||
@@ -162,3 +163,59 @@ def test_shortcuts_setup(client):
|
||||
assert "title" in data
|
||||
assert "actions" in data
|
||||
assert len(data["actions"]) >= 4
|
||||
|
||||
|
||||
# ── Marketplace UI route ──────────────────────────────────────────────────────
|
||||
|
||||
def test_marketplace_ui_renders_html(client):
|
||||
response = client.get("/marketplace/ui")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "Agent Marketplace" in response.text
|
||||
|
||||
|
||||
def test_marketplace_ui_shows_all_agents(client):
|
||||
response = client.get("/marketplace/ui")
|
||||
assert response.status_code == 200
|
||||
# All seven catalog entries should appear
|
||||
for name in ["Timmy", "Echo", "Mace", "Helm", "Seer", "Forge", "Quill"]:
|
||||
assert name in response.text, f"{name} not found in marketplace UI"
|
||||
|
||||
|
||||
def test_marketplace_ui_shows_timmy_free(client):
|
||||
response = client.get("/marketplace/ui")
|
||||
assert "FREE" in response.text
|
||||
|
||||
|
||||
def test_marketplace_ui_shows_planned_status(client):
|
||||
response = client.get("/marketplace/ui")
|
||||
# Personas not yet in registry show as "planned"
|
||||
assert "planned" in response.text
|
||||
|
||||
|
||||
def test_marketplace_ui_shows_active_timmy(client):
|
||||
response = client.get("/marketplace/ui")
|
||||
# Timmy is always active even without registry entry
|
||||
assert "active" in response.text
|
||||
|
||||
|
||||
# ── Marketplace enriched data ─────────────────────────────────────────────────
|
||||
|
||||
def test_marketplace_enriched_includes_stats_fields(client):
|
||||
response = client.get("/marketplace")
|
||||
agents = response.json()["agents"]
|
||||
for a in agents:
|
||||
assert "tasks_completed" in a, f"Missing tasks_completed in {a['id']}"
|
||||
assert "total_earned" in a, f"Missing total_earned in {a['id']}"
|
||||
|
||||
|
||||
def test_marketplace_persona_spawned_changes_status(client):
|
||||
"""Spawning a persona into the registry changes its marketplace status."""
|
||||
# Spawn Echo via swarm route
|
||||
spawn_resp = client.post("/swarm/spawn", data={"name": "Echo"})
|
||||
assert spawn_resp.status_code == 200
|
||||
|
||||
# Echo should now show as idle in the marketplace
|
||||
resp = client.get("/marketplace")
|
||||
agents = {a["id"]: a for a in resp.json()["agents"]}
|
||||
assert agents["echo"]["status"] == "idle"
|
||||
|
||||
187
tests/test_swarm_personas.py
Normal file
187
tests/test_swarm_personas.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Tests for swarm.personas and swarm.persona_node."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
# ── Fixture: redirect SQLite DB to a temp directory ──────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tmp_swarm_db(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "swarm.db"
|
||||
monkeypatch.setattr("swarm.tasks.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.registry.DB_PATH", db_path)
|
||||
monkeypatch.setattr("swarm.stats.DB_PATH", db_path)
|
||||
yield db_path
|
||||
|
||||
|
||||
# ── personas.py ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_all_six_personas_defined():
|
||||
from swarm.personas import PERSONAS
|
||||
expected = {"echo", "mace", "helm", "seer", "forge", "quill"}
|
||||
assert expected == set(PERSONAS.keys())
|
||||
|
||||
|
||||
def test_persona_has_required_fields():
|
||||
from swarm.personas import PERSONAS
|
||||
required = {"id", "name", "role", "description", "capabilities",
|
||||
"rate_sats", "bid_base", "bid_jitter", "preferred_keywords"}
|
||||
for pid, meta in PERSONAS.items():
|
||||
missing = required - set(meta.keys())
|
||||
assert not missing, f"Persona {pid!r} missing: {missing}"
|
||||
|
||||
|
||||
def test_get_persona_returns_correct_entry():
|
||||
from swarm.personas import get_persona
|
||||
echo = get_persona("echo")
|
||||
assert echo is not None
|
||||
assert echo["name"] == "Echo"
|
||||
assert echo["role"] == "Research Analyst"
|
||||
|
||||
|
||||
def test_get_persona_returns_none_for_unknown():
|
||||
from swarm.personas import get_persona
|
||||
assert get_persona("bogus") is None
|
||||
|
||||
|
||||
def test_list_personas_returns_all_six():
|
||||
from swarm.personas import list_personas
|
||||
personas = list_personas()
|
||||
assert len(personas) == 6
|
||||
|
||||
|
||||
def test_persona_capabilities_are_comma_strings():
|
||||
from swarm.personas import PERSONAS
|
||||
for pid, meta in PERSONAS.items():
|
||||
assert isinstance(meta["capabilities"], str), \
|
||||
f"{pid} capabilities should be a comma-separated string"
|
||||
assert "," in meta["capabilities"] or len(meta["capabilities"]) > 0
|
||||
|
||||
|
||||
def test_persona_preferred_keywords_nonempty():
|
||||
from swarm.personas import PERSONAS
|
||||
for pid, meta in PERSONAS.items():
|
||||
assert len(meta["preferred_keywords"]) > 0, \
|
||||
f"{pid} must have at least one preferred keyword"
|
||||
|
||||
|
||||
# ── persona_node.py ───────────────────────────────────────────────────────────
|
||||
|
||||
def _make_persona_node(persona_id="echo", agent_id="persona-1"):
|
||||
from swarm.comms import SwarmComms
|
||||
from swarm.persona_node import PersonaNode
|
||||
comms = SwarmComms(redis_url="redis://localhost:9999") # in-memory fallback
|
||||
return PersonaNode(persona_id=persona_id, agent_id=agent_id, comms=comms)
|
||||
|
||||
|
||||
def test_persona_node_inherits_name():
|
||||
node = _make_persona_node("echo")
|
||||
assert node.name == "Echo"
|
||||
|
||||
|
||||
def test_persona_node_inherits_capabilities():
|
||||
node = _make_persona_node("mace")
|
||||
assert "security" in node.capabilities
|
||||
|
||||
|
||||
def test_persona_node_has_rate_sats():
|
||||
node = _make_persona_node("quill")
|
||||
from swarm.personas import PERSONAS
|
||||
assert node.rate_sats == PERSONAS["quill"]["rate_sats"]
|
||||
|
||||
|
||||
def test_persona_node_raises_on_unknown_persona():
|
||||
from swarm.comms import SwarmComms
|
||||
from swarm.persona_node import PersonaNode
|
||||
comms = SwarmComms(redis_url="redis://localhost:9999")
|
||||
with pytest.raises(KeyError):
|
||||
PersonaNode(persona_id="ghost", agent_id="x", comms=comms)
|
||||
|
||||
|
||||
def test_persona_node_bids_low_on_preferred_task():
|
||||
node = _make_persona_node("echo") # prefers research/summarize
|
||||
bids = [node._compute_bid("please research and summarize this topic") for _ in range(20)]
|
||||
avg = sum(bids) / len(bids)
|
||||
# Should cluster around bid_base (35) not the off-spec inflated value
|
||||
assert avg < 80, f"Expected low bids on preferred task, got avg={avg:.1f}"
|
||||
|
||||
|
||||
def test_persona_node_bids_higher_on_off_spec_task():
|
||||
node = _make_persona_node("echo") # echo doesn't prefer "deploy kubernetes"
|
||||
bids = [node._compute_bid("deploy kubernetes cluster") for _ in range(20)]
|
||||
avg = sum(bids) / len(bids)
|
||||
# Off-spec: bid inflated by _OFF_SPEC_MULTIPLIER
|
||||
assert avg > 40, f"Expected higher bids on off-spec task, got avg={avg:.1f}"
|
||||
|
||||
|
||||
def test_persona_node_preferred_beats_offspec():
|
||||
"""A preferred-task bid should be lower than an off-spec bid on average."""
|
||||
node = _make_persona_node("forge") # prefers code/bug/test
|
||||
on_spec = [node._compute_bid("write tests and fix bugs in the code") for _ in range(30)]
|
||||
off_spec = [node._compute_bid("research market trends in finance") for _ in range(30)]
|
||||
assert sum(on_spec) / 30 < sum(off_spec) / 30
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persona_node_join_registers_in_registry():
|
||||
from swarm import registry
|
||||
node = _make_persona_node("helm", agent_id="helm-join-test")
|
||||
await node.join()
|
||||
assert node.is_joined is True
|
||||
rec = registry.get_agent("helm-join-test")
|
||||
assert rec is not None
|
||||
assert rec.name == "Helm"
|
||||
assert "devops" in rec.capabilities
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persona_node_submits_bid_on_task():
|
||||
from swarm.comms import SwarmComms, CHANNEL_BIDS
|
||||
comms = SwarmComms(redis_url="redis://localhost:9999")
|
||||
from swarm.persona_node import PersonaNode
|
||||
node = PersonaNode(persona_id="quill", agent_id="quill-bid-1", comms=comms)
|
||||
await node.join()
|
||||
|
||||
received = []
|
||||
comms.subscribe(CHANNEL_BIDS, lambda msg: received.append(msg))
|
||||
comms.post_task("task-quill-1", "write documentation for the API")
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].data["agent_id"] == "quill-bid-1"
|
||||
assert received[0].data["bid_sats"] >= 1
|
||||
|
||||
|
||||
# ── coordinator.spawn_persona ─────────────────────────────────────────────────
|
||||
|
||||
def test_coordinator_spawn_persona_registers_agent():
|
||||
from swarm.coordinator import SwarmCoordinator
|
||||
from swarm import registry
|
||||
coord = SwarmCoordinator()
|
||||
result = coord.spawn_persona("seer")
|
||||
assert result["name"] == "Seer"
|
||||
assert result["persona_id"] == "seer"
|
||||
assert "agent_id" in result
|
||||
agents = registry.list_agents()
|
||||
assert any(a.name == "Seer" for a in agents)
|
||||
|
||||
|
||||
def test_coordinator_spawn_persona_raises_on_unknown():
|
||||
from swarm.coordinator import SwarmCoordinator
|
||||
coord = SwarmCoordinator()
|
||||
with pytest.raises(ValueError, match="Unknown persona"):
|
||||
coord.spawn_persona("ghost")
|
||||
|
||||
|
||||
def test_coordinator_spawn_all_personas():
|
||||
from swarm.coordinator import SwarmCoordinator
|
||||
from swarm import registry
|
||||
coord = SwarmCoordinator()
|
||||
names = []
|
||||
for pid in ["echo", "mace", "helm", "seer", "forge", "quill"]:
|
||||
result = coord.spawn_persona(pid)
|
||||
names.append(result["name"])
|
||||
agents = registry.list_agents()
|
||||
registered = {a.name for a in agents}
|
||||
for name in names:
|
||||
assert name in registered
|
||||
133
tests/test_swarm_stats.py
Normal file
133
tests/test_swarm_stats.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Tests for swarm.stats — bid history persistence."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tmp_swarm_db(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "swarm.db"
|
||||
monkeypatch.setattr("swarm.stats.DB_PATH", db_path)
|
||||
yield db_path
|
||||
|
||||
|
||||
# ── record_bid ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_record_bid_returns_id():
|
||||
from swarm.stats import record_bid
|
||||
row_id = record_bid("task-1", "agent-1", 42)
|
||||
assert isinstance(row_id, str)
|
||||
assert len(row_id) > 0
|
||||
|
||||
|
||||
def test_record_multiple_bids():
|
||||
from swarm.stats import record_bid, list_bids
|
||||
record_bid("task-2", "agent-A", 30)
|
||||
record_bid("task-2", "agent-B", 50)
|
||||
bids = list_bids("task-2")
|
||||
assert len(bids) == 2
|
||||
agent_ids = {b["agent_id"] for b in bids}
|
||||
assert "agent-A" in agent_ids
|
||||
assert "agent-B" in agent_ids
|
||||
|
||||
|
||||
def test_bid_not_won_by_default():
|
||||
from swarm.stats import record_bid, list_bids
|
||||
record_bid("task-3", "agent-1", 20)
|
||||
bids = list_bids("task-3")
|
||||
assert bids[0]["won"] == 0
|
||||
|
||||
|
||||
def test_record_bid_won_flag():
|
||||
from swarm.stats import record_bid, list_bids
|
||||
record_bid("task-4", "agent-1", 10, won=True)
|
||||
bids = list_bids("task-4")
|
||||
assert bids[0]["won"] == 1
|
||||
|
||||
|
||||
# ── mark_winner ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_mark_winner_updates_row():
|
||||
from swarm.stats import record_bid, mark_winner, list_bids
|
||||
record_bid("task-5", "agent-X", 55)
|
||||
record_bid("task-5", "agent-Y", 30)
|
||||
updated = mark_winner("task-5", "agent-Y")
|
||||
assert updated >= 1
|
||||
bids = {b["agent_id"]: b for b in list_bids("task-5")}
|
||||
assert bids["agent-Y"]["won"] == 1
|
||||
assert bids["agent-X"]["won"] == 0
|
||||
|
||||
|
||||
def test_mark_winner_nonexistent_task_returns_zero():
|
||||
from swarm.stats import mark_winner
|
||||
updated = mark_winner("no-such-task", "no-such-agent")
|
||||
assert updated == 0
|
||||
|
||||
|
||||
# ── get_agent_stats ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_get_agent_stats_no_bids():
|
||||
from swarm.stats import get_agent_stats
|
||||
stats = get_agent_stats("ghost-agent")
|
||||
assert stats["total_bids"] == 0
|
||||
assert stats["tasks_won"] == 0
|
||||
assert stats["total_earned"] == 0
|
||||
|
||||
|
||||
def test_get_agent_stats_after_bids():
|
||||
from swarm.stats import record_bid, mark_winner, get_agent_stats
|
||||
record_bid("t10", "agent-Z", 40)
|
||||
record_bid("t11", "agent-Z", 55, won=True)
|
||||
mark_winner("t11", "agent-Z")
|
||||
stats = get_agent_stats("agent-Z")
|
||||
assert stats["total_bids"] == 2
|
||||
assert stats["tasks_won"] >= 1
|
||||
assert stats["total_earned"] >= 55
|
||||
|
||||
|
||||
def test_get_agent_stats_isolates_by_agent():
|
||||
from swarm.stats import record_bid, mark_winner, get_agent_stats
|
||||
record_bid("t20", "agent-A", 20, won=True)
|
||||
record_bid("t20", "agent-B", 30)
|
||||
mark_winner("t20", "agent-A")
|
||||
stats_a = get_agent_stats("agent-A")
|
||||
stats_b = get_agent_stats("agent-B")
|
||||
assert stats_a["total_earned"] >= 20
|
||||
assert stats_b["total_earned"] == 0
|
||||
|
||||
|
||||
# ── get_all_agent_stats ───────────────────────────────────────────────────────
|
||||
|
||||
def test_get_all_agent_stats_empty():
|
||||
from swarm.stats import get_all_agent_stats
|
||||
assert get_all_agent_stats() == {}
|
||||
|
||||
|
||||
def test_get_all_agent_stats_multiple_agents():
|
||||
from swarm.stats import record_bid, get_all_agent_stats
|
||||
record_bid("t30", "alice", 10)
|
||||
record_bid("t31", "bob", 20)
|
||||
record_bid("t32", "alice", 15)
|
||||
all_stats = get_all_agent_stats()
|
||||
assert "alice" in all_stats
|
||||
assert "bob" in all_stats
|
||||
assert all_stats["alice"]["total_bids"] == 2
|
||||
assert all_stats["bob"]["total_bids"] == 1
|
||||
|
||||
|
||||
# ── list_bids ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_bids_all():
|
||||
from swarm.stats import record_bid, list_bids
|
||||
record_bid("t40", "a1", 10)
|
||||
record_bid("t41", "a2", 20)
|
||||
all_bids = list_bids()
|
||||
assert len(all_bids) >= 2
|
||||
|
||||
|
||||
def test_list_bids_filtered_by_task():
|
||||
from swarm.stats import record_bid, list_bids
|
||||
record_bid("task-filter", "a1", 10)
|
||||
record_bid("task-other", "a2", 20)
|
||||
filtered = list_bids("task-filter")
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0]["task_id"] == "task-filter"
|
||||
Reference in New Issue
Block a user