The standalone prototype should be the root-level project shape for fengqun while preserving the existing planning documents already at the root. This keeps README, examples, tests, and the Python package directly discoverable without deleting the prior docs. Constraint: User clarified that swarm-minimal is the repository root, but other existing root files must remain. Rejected: Deleting existing root docs | They are part of the fengqun repository context and were explicitly protected. Confidence: high Scope-risk: narrow Directive: Keep secrets in ignored .env only; do not commit live credentials. Tested: python3 -B -m unittest discover -s tests; git diff --check; secret-pattern scan showed only placeholders/test values/task-id false positives. Not-tested: Remote web UI rendering after push.
344 lines
11 KiB
Python
344 lines
11 KiB
Python
"""Minimal swarm loop.
|
|
|
|
The prototype models four shared resources:
|
|
|
|
- task pool
|
|
- pheromone / score table
|
|
- shared state
|
|
- result convergence
|
|
|
|
Storage is in-memory here. The same methods can later be backed by PostgreSQL,
|
|
Redis, Blob Storage, and a secret store once Azure resources are provided.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from enum import StrEnum
|
|
from time import time
|
|
from typing import Callable
|
|
from uuid import uuid4
|
|
|
|
|
|
class TaskStatus(StrEnum):
|
|
PENDING = "pending"
|
|
RUNNING = "running"
|
|
DONE = "done"
|
|
FAILED = "failed"
|
|
|
|
|
|
@dataclass
|
|
class Task:
|
|
kind: str
|
|
input: str
|
|
id: str = field(default_factory=lambda: uuid4().hex)
|
|
status: TaskStatus = TaskStatus.PENDING
|
|
claimed_by: str | None = None
|
|
output: str | None = None
|
|
score: float = 0.0
|
|
error: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Observation:
|
|
task_id: str
|
|
agent_id: str
|
|
signal: str
|
|
score_delta: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Agent:
|
|
id: str
|
|
capability: str
|
|
run: Callable[[Task, dict[str, str]], tuple[str, float]]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SwarmResult:
|
|
run_id: str
|
|
goal: str
|
|
accepted_output: str
|
|
accepted_task_id: str
|
|
accepted_score: float
|
|
completed_tasks: int
|
|
observations: tuple[Observation, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConsensusVote:
|
|
agent_id: str
|
|
role: str
|
|
candidate: str
|
|
confidence: float
|
|
evidence: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConsensusRound:
|
|
index: int
|
|
votes: tuple[ConsensusVote, ...]
|
|
candidate_scores: dict[str, float]
|
|
leader: str
|
|
leader_share: float
|
|
margin: float
|
|
converged: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConsensusResult:
|
|
accepted_candidate: str
|
|
accepted_score: float
|
|
converged: bool
|
|
rounds: tuple[ConsensusRound, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConsensusAgent:
|
|
id: str
|
|
role: str
|
|
weight: float
|
|
vote: Callable[[dict[str, float], dict[str, str], int], ConsensusVote]
|
|
|
|
|
|
class InMemorySwarmStore:
|
|
"""In-memory implementation of the four shared resources."""
|
|
|
|
def __init__(self) -> None:
|
|
self.tasks: dict[str, Task] = {}
|
|
self.pheromones: dict[str, float] = {}
|
|
self.shared_state: dict[str, str] = {}
|
|
self.convergence: dict[str, SwarmResult] = {}
|
|
self.observations: list[Observation] = []
|
|
|
|
def add_task(self, task: Task) -> None:
|
|
self.tasks[task.id] = task
|
|
self.pheromones.setdefault(task.id, 0.0)
|
|
|
|
def claim_next(self, agent: Agent) -> Task | None:
|
|
candidates = [
|
|
task
|
|
for task in self.tasks.values()
|
|
if task.status == TaskStatus.PENDING and task.kind == agent.capability
|
|
]
|
|
if not candidates:
|
|
return None
|
|
|
|
task = sorted(candidates, key=lambda item: self.pheromones[item.id], reverse=True)[0]
|
|
task.status = TaskStatus.RUNNING
|
|
task.claimed_by = agent.id
|
|
self.shared_state[f"task:{task.id}:claimed_by"] = agent.id
|
|
self.shared_state[f"agent:{agent.id}:heartbeat"] = str(int(time()))
|
|
return task
|
|
|
|
def complete_task(self, task: Task, agent: Agent, output: str, score: float) -> None:
|
|
task.status = TaskStatus.DONE
|
|
task.output = output
|
|
task.score = score
|
|
self.pheromones[task.id] = self.pheromones.get(task.id, 0.0) + score
|
|
observation = Observation(
|
|
task_id=task.id,
|
|
agent_id=agent.id,
|
|
signal=f"{task.kind}:done",
|
|
score_delta=score,
|
|
)
|
|
self.observations.append(observation)
|
|
self.shared_state[f"task:{task.id}:status"] = TaskStatus.DONE.value
|
|
|
|
def fail_task(self, task: Task, agent: Agent, error: str) -> None:
|
|
task.status = TaskStatus.FAILED
|
|
task.error = error
|
|
self.pheromones[task.id] = self.pheromones.get(task.id, 0.0) - 1.0
|
|
self.observations.append(
|
|
Observation(
|
|
task_id=task.id,
|
|
agent_id=agent.id,
|
|
signal=f"{task.kind}:failed",
|
|
score_delta=-1.0,
|
|
)
|
|
)
|
|
self.shared_state[f"task:{task.id}:status"] = TaskStatus.FAILED.value
|
|
|
|
def converge(self, run_id: str, goal: str) -> SwarmResult:
|
|
completed = [task for task in self.tasks.values() if task.status == TaskStatus.DONE]
|
|
if not completed:
|
|
raise RuntimeError("cannot converge without completed tasks")
|
|
|
|
winner = sorted(completed, key=lambda task: task.score, reverse=True)[0]
|
|
result = SwarmResult(
|
|
run_id=run_id,
|
|
goal=goal,
|
|
accepted_output=winner.output or "",
|
|
accepted_task_id=winner.id,
|
|
accepted_score=winner.score,
|
|
completed_tasks=len(completed),
|
|
observations=tuple(self.observations),
|
|
)
|
|
self.convergence[run_id] = result
|
|
self.shared_state[f"run:{run_id}:status"] = "converged"
|
|
return result
|
|
|
|
|
|
class SwarmCoordinator:
|
|
"""Coordinates a single minimal swarm run."""
|
|
|
|
def __init__(self, store: InMemorySwarmStore, agents: list[Agent]) -> None:
|
|
self.store = store
|
|
self.agents = agents
|
|
|
|
def submit_goal(self, goal: str) -> str:
|
|
if not goal.strip():
|
|
raise ValueError("goal is required")
|
|
|
|
run_id = uuid4().hex
|
|
self.store.shared_state[f"run:{run_id}:goal"] = goal
|
|
self.store.shared_state[f"run:{run_id}:status"] = "running"
|
|
self.store.add_task(Task(kind="plan", input=goal))
|
|
self.store.add_task(Task(kind="build", input=goal))
|
|
self.store.add_task(Task(kind="verify", input=goal))
|
|
return run_id
|
|
|
|
def run_until_converged(self, run_id: str) -> SwarmResult:
|
|
goal = self.store.shared_state.get(f"run:{run_id}:goal")
|
|
if goal is None:
|
|
raise KeyError(f"unknown run_id: {run_id}")
|
|
|
|
made_progress = True
|
|
while made_progress:
|
|
made_progress = False
|
|
for agent in self.agents:
|
|
task = self.store.claim_next(agent)
|
|
if task is None:
|
|
continue
|
|
made_progress = True
|
|
try:
|
|
output, score = agent.run(task, self.store.shared_state)
|
|
except Exception as exc: # pragma: no cover - defensive branch.
|
|
self.store.fail_task(task, agent, str(exc))
|
|
continue
|
|
self.store.complete_task(task, agent, output, score)
|
|
|
|
return self.store.converge(run_id, goal)
|
|
|
|
|
|
def default_agents() -> list[Agent]:
|
|
"""Return three simple agents for the minimal closed loop."""
|
|
|
|
return [
|
|
Agent(
|
|
id="planner",
|
|
capability="plan",
|
|
run=lambda task, _: (f"Plan for: {task.input}", 0.72),
|
|
),
|
|
Agent(
|
|
id="builder",
|
|
capability="build",
|
|
run=lambda task, _: (f"Build minimal path for: {task.input}", 0.84),
|
|
),
|
|
Agent(
|
|
id="verifier",
|
|
capability="verify",
|
|
run=lambda task, _: (f"Verify acceptance for: {task.input}", 0.91),
|
|
),
|
|
]
|
|
|
|
|
|
class ConsensusSwarm:
|
|
"""Run a small multi-round swarm convergence process.
|
|
|
|
This is stricter than ``InMemorySwarmStore.converge``. It does not simply
|
|
select the highest completed task. Each agent observes the shared score map,
|
|
casts a role-specific vote, and the environment accumulates weighted
|
|
pheromone-like evidence until one candidate crosses a share threshold and a
|
|
minimum margin.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
agents: list[ConsensusAgent],
|
|
*,
|
|
threshold: float = 0.62,
|
|
min_margin: float = 0.12,
|
|
max_rounds: int = 5,
|
|
evaporation: float = 0.85,
|
|
) -> None:
|
|
if not agents:
|
|
raise ValueError("at least one consensus agent is required")
|
|
if not 0 < threshold <= 1:
|
|
raise ValueError("threshold must be between 0 and 1")
|
|
if not 0 <= evaporation <= 1:
|
|
raise ValueError("evaporation must be between 0 and 1")
|
|
self.agents = agents
|
|
self.threshold = threshold
|
|
self.min_margin = min_margin
|
|
self.max_rounds = max_rounds
|
|
self.evaporation = evaporation
|
|
self.shared_state: dict[str, str] = {}
|
|
self.candidate_scores: dict[str, float] = {}
|
|
|
|
def run(self, goal: str) -> ConsensusResult:
|
|
if not goal.strip():
|
|
raise ValueError("goal is required")
|
|
|
|
self.shared_state["goal"] = goal
|
|
rounds: list[ConsensusRound] = []
|
|
for index in range(1, self.max_rounds + 1):
|
|
self._evaporate_scores()
|
|
votes = tuple(agent.vote(dict(self.candidate_scores), self.shared_state, index) for agent in self.agents)
|
|
for agent, vote in zip(self.agents, votes, strict=True):
|
|
if vote.agent_id != agent.id:
|
|
raise ValueError(f"vote agent mismatch: {vote.agent_id} != {agent.id}")
|
|
if vote.candidate not in self.candidate_scores:
|
|
self.candidate_scores[vote.candidate] = 0.0
|
|
self.candidate_scores[vote.candidate] += max(0.0, vote.confidence) * agent.weight
|
|
self.shared_state[f"round:{index}:agent:{agent.id}:candidate"] = vote.candidate
|
|
self.shared_state[f"round:{index}:agent:{agent.id}:evidence"] = vote.evidence
|
|
|
|
leader, leader_score, share, margin = self._leader()
|
|
converged = share >= self.threshold and margin >= self.min_margin
|
|
round_result = ConsensusRound(
|
|
index=index,
|
|
votes=votes,
|
|
candidate_scores=dict(self.candidate_scores),
|
|
leader=leader,
|
|
leader_share=share,
|
|
margin=margin,
|
|
converged=converged,
|
|
)
|
|
rounds.append(round_result)
|
|
self.shared_state["active_candidate"] = leader
|
|
self.shared_state["leader_share"] = f"{share:.4f}"
|
|
self.shared_state["margin"] = f"{margin:.4f}"
|
|
if converged:
|
|
self.shared_state["status"] = "converged"
|
|
return ConsensusResult(
|
|
accepted_candidate=leader,
|
|
accepted_score=leader_score,
|
|
converged=True,
|
|
rounds=tuple(rounds),
|
|
)
|
|
|
|
leader, leader_score, _, _ = self._leader()
|
|
self.shared_state["status"] = "not_converged"
|
|
return ConsensusResult(
|
|
accepted_candidate=leader,
|
|
accepted_score=leader_score,
|
|
converged=False,
|
|
rounds=tuple(rounds),
|
|
)
|
|
|
|
def _evaporate_scores(self) -> None:
|
|
for candidate in list(self.candidate_scores):
|
|
self.candidate_scores[candidate] *= self.evaporation
|
|
|
|
def _leader(self) -> tuple[str, float, float, float]:
|
|
if not self.candidate_scores:
|
|
return "", 0.0, 0.0, 0.0
|
|
ranked = sorted(self.candidate_scores.items(), key=lambda item: item[1], reverse=True)
|
|
leader, leader_score = ranked[0]
|
|
second_score = ranked[1][1] if len(ranked) > 1 else 0.0
|
|
total = sum(max(0.0, score) for _, score in ranked)
|
|
share = leader_score / total if total else 0.0
|
|
margin = leader_score - second_score
|
|
return leader, leader_score, share, margin
|