Files
fengqun/swarm_minimal/core.py
T
gongzhiyong 111be3e435 Promote the minimal swarm prototype to the repository root
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.
2026-05-16 13:32:11 +08:00

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