回应 Fasthei 终审三点: 1) [P1 文档/代码冲突 + 死代码] 删除从不被调用的 *_enabled() helper(autonomous_tasks.proposals_enabled / task_competition.task_competition_enabled / convergence.convergence_report_enabled)及其 import os;模块 docstring 与四份协议文档(autonomous-task-generation / task-competition-protocol / review-loop-protocol / convergence-protocol)从"默认关/未接入/待 PR/cutover 转无条件"全部改为 "无条件接入(无开关)",删除引用死 helper 的过时集成代码样例;同步删除三个模块单测里的 "flag default OFF" 断言。 2) [P1 验收] #6 "Closes" 降为 "Refs":#6 DoD 需 ARB 决策记录链接,当前只有 owner 指示断言、无链接。 product-positioning.md 改为如实记录决策来源(owner 指示 + 本 PR + 文档)并把"补 ARB 记录链接(或 owner 明确接受断言)"列为关闭 #6 的前置;纠正其"flag 门控、默认行为不变"的过时表述(重构已无条件)。 3) [P2 契约卫生] assess_swarm_health 不再 emit_event("swarm.health")(避免向订阅全部的 Manager 回调 投递未注册事件);改为存 run.metadata["health"] + 内部 health_log。test-swarm-guard 相应断言 "无 swarm.health 外发 + 内部 health_log 已记"。 本地受影响 11 套全绿。影响范围:agent_swarm(orchestrator 模块/文档/测试);不改 Manager↔Swarm 契约。 Refs #6 Refs #7 Refs #8 Refs #11 Refs #12 Refs #18 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
355 lines
15 KiB
Python
355 lines
15 KiB
Python
"""Agent task-competition protocol: bidding, yielding, takeover and arbitration.
|
|
|
|
Resolves issue #8 — "Agent 自主竞争机制缺失" — by giving the swarm a *real, tested*
|
|
mechanism for agents to (1) bid for a task, (2) yield a task back with a reason and an
|
|
optional recommendation, (3) request takeover of a task another agent holds, and (4)
|
|
have a deterministic arbitrator pick a winner with a full, auditable explanation.
|
|
|
|
This module is INTENTIONALLY self-contained and side-effect free:
|
|
- It defines the message/data models (`TaskBid`, `TaskYield`, `TaskTakeoverRequest`,
|
|
`TaskArbitrationResult`), the event-payload builders (`task.bid_submitted`,
|
|
`task.yielded`, `task.takeover_requested`, `task.arbitrated`), and a pure
|
|
`arbitrate(bids, policy) -> TaskArbitrationResult`.
|
|
- It does NOT touch Redis, the WebSocket loop, dispatch, the Manager callback stream,
|
|
billing, or the approval chain. Wiring those is documented (not done) in
|
|
`docs/swarm/task-competition-protocol.md` §「集成说明」. This honours org rule #9:
|
|
the arbitration math is genuinely implemented and tested; the integration is
|
|
explicitly declared as not wired.
|
|
|
|
Determinism (DoD): `arbitrate` is a pure function of its inputs. Given the same bids and
|
|
the same policy it always returns the same winner, the same per-bid scores, and the same
|
|
ordered loser list. Ties break on a stable, documented key (score desc, then agent_id
|
|
asc) so there is never RNG or dict-ordering ambiguity. This mirrors the auditability
|
|
contract the ACO `DecisionEngine` already established (`decision_engine.py`): the result
|
|
carries enough to re-derive and explain the choice without any live state.
|
|
|
|
τ reuse: `arbitrate` accepts an optional `historical_success` map (per-agent τ in
|
|
[0, 1]) — the same earned-reputation signal the ACO pheromone trail produces
|
|
(`pheromone:{agent_role}:{agent_id}`). The arbitrator treats it as ONE weighted input
|
|
among capability fit, budget headroom, risk and current load; it is never the sole
|
|
decider, and when absent it contributes a documented neutral 0.5 (same for every bid →
|
|
no ranking distortion), exactly as `decision_engine.compute_eta` handles missing
|
|
confidence.
|
|
|
|
Code English; companion doc Simplified Chinese, per PROJECT_STANDARD.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# UNCONDITIONAL: the competition protocol is the swarm's only contention mechanism (no enable
|
|
# flag — this repo is the swarm runtime). The WS task_bid/task_yield/task_takeover_request branches
|
|
# + their handlers in main.py are always active.
|
|
|
|
|
|
# --- arbitration policy ------------------------------------------------------
|
|
# Neutral constant for a missing τ signal: identical for every bid, so it cannot distort
|
|
# ranking — it only shifts every score by the same amount. Mirrors decision_engine's
|
|
# CONFIDENCE_NEUTRAL=0.5 treatment of absent confidence (honesty rule #9: a missing
|
|
# signal is a documented neutral, never a fabricated value).
|
|
HISTORICAL_SUCCESS_NEUTRAL = 0.5
|
|
|
|
# Normalisation anchors so heterogeneous bid fields collapse to [0, 1] before weighting.
|
|
# estimated_cost/time are "lower is better"; they are scored as headroom against a cap.
|
|
COST_NORM = 1.0 # estimated_cost expressed as a fraction of run budget; >=1 ⇒ no headroom
|
|
TIME_NORM_SECONDS = 600.0 # estimated_time at which the time score saturates to 0 (10 min)
|
|
LOAD_NORM_SLOTS = 4.0 # current_load (# in-flight tasks) at which the load score hits 0
|
|
|
|
|
|
class ArbitrationPolicy(BaseModel):
|
|
"""Weights for the arbitration scoring function. All non-negative; need not sum to 1
|
|
(the score is a weighted sum, comparisons are relative). Deterministic given inputs.
|
|
|
|
The default mirrors the issue's stated criteria order of importance:
|
|
capability + historical_success(τ) dominate, budget/risk/load are correctors.
|
|
"""
|
|
w_confidence: float = 0.20 # bidder's self-reported confidence in TaskBid
|
|
w_capability: float = 0.25 # capability fit vs. the task's required capabilities
|
|
w_historical_success: float = 0.25 # τ — earned reputation (ACO pheromone trail)
|
|
w_budget: float = 0.10 # cost headroom (cheaper bid scores higher)
|
|
w_time: float = 0.05 # speed (faster estimate scores higher)
|
|
w_risk: float = 0.10 # lower risk_score scores higher
|
|
w_load: float = 0.05 # less-loaded agent scores higher
|
|
|
|
# Minimum score gap for a *clear* winner; below it the result is flagged contested
|
|
# (still deterministic — the winner is the stable-sort head — but callers can choose
|
|
# to escalate to a human/Manager approval step rather than auto-assign).
|
|
decisive_margin: float = 0.02
|
|
|
|
|
|
# --- message / data models ---------------------------------------------------
|
|
class TaskBid(BaseModel):
|
|
"""An agent's bid to take (or keep) a task.
|
|
|
|
confidence/risk_score in [0, 1]; estimated_cost is a fraction of the run budget
|
|
(0 = free, >=1 = at/over budget); estimated_time in seconds; current_load = number of
|
|
tasks the bidding agent currently has in flight.
|
|
"""
|
|
type: str = "task_bid"
|
|
task_id: str
|
|
agent_id: str
|
|
confidence: float = 0.5
|
|
estimated_cost: float = 0.0
|
|
estimated_time: float = 0.0
|
|
risk_score: float = 0.0
|
|
reason: str = ""
|
|
capabilities: List[str] = Field(default_factory=list)
|
|
current_load: int = 0
|
|
|
|
|
|
class TaskYield(BaseModel):
|
|
"""An agent voluntarily releasing a task back for re-competition, with rationale and
|
|
an optional recommendation of who should pick it up next."""
|
|
type: str = "task_yield"
|
|
task_id: str
|
|
agent_id: str
|
|
release_with_reason: str
|
|
recommend_agent: Optional[str] = None
|
|
|
|
|
|
class TaskTakeoverRequest(BaseModel):
|
|
"""An agent asking to take over a task currently held by another agent (e.g. it is
|
|
stalled, or the requester is a better fit). Carries a bid so the same arbitrator can
|
|
weigh requester vs. incumbent on identical terms."""
|
|
type: str = "task_takeover_request"
|
|
task_id: str
|
|
requesting_agent_id: str
|
|
current_agent_id: Optional[str] = None
|
|
reason: str = ""
|
|
bid: Optional[TaskBid] = None
|
|
|
|
|
|
class ArbitrationScore(BaseModel):
|
|
"""Per-bid, fully broken-down score — the audit trail for one competitor."""
|
|
agent_id: str
|
|
total: float
|
|
components: Dict[str, float]
|
|
|
|
|
|
class TaskArbitrationResult(BaseModel):
|
|
"""The deterministic, explainable outcome of arbitrating a set of bids."""
|
|
type: str = "task_arbitration_result"
|
|
task_id: str
|
|
winner_agent_id: Optional[str]
|
|
reason: str
|
|
decisive: bool
|
|
scores: List[ArbitrationScore]
|
|
losers: List[str]
|
|
policy: ArbitrationPolicy
|
|
arbitrated_at: float = Field(default_factory=time.time)
|
|
|
|
|
|
# --- scoring -----------------------------------------------------------------
|
|
def _capability_fit(bid_caps: List[str], required: List[str]) -> float:
|
|
"""Fraction of required capabilities the bidder covers. No requirement ⇒ perfect fit.
|
|
Pure set math — same shape as task_queue.can_agent_run_task / decision_engine match."""
|
|
req = set(required or [])
|
|
if not req:
|
|
return 1.0
|
|
caps = set(bid_caps or [])
|
|
return len(req & caps) / len(req)
|
|
|
|
|
|
def _clamp01(x: float) -> float:
|
|
return max(0.0, min(1.0, x))
|
|
|
|
|
|
def _score_bid(
|
|
bid: TaskBid,
|
|
*,
|
|
required_capabilities: List[str],
|
|
historical_success: Dict[str, float],
|
|
policy: ArbitrationPolicy,
|
|
) -> ArbitrationScore:
|
|
"""Map one bid to a weighted scalar with every component recorded for audit.
|
|
|
|
Each component is normalised to [0, 1] first (so weights are comparable), then
|
|
multiplied by its policy weight. "Lower is better" fields (cost/time/risk/load) are
|
|
converted to headroom (1 - normalised) so that higher always means better.
|
|
"""
|
|
confidence = _clamp01(bid.confidence)
|
|
capability = _capability_fit(bid.capabilities, required_capabilities)
|
|
# τ: documented neutral when this agent has no recorded reputation yet.
|
|
tau = historical_success.get(bid.agent_id, HISTORICAL_SUCCESS_NEUTRAL)
|
|
tau = _clamp01(tau)
|
|
budget = _clamp01(1.0 - bid.estimated_cost / COST_NORM)
|
|
speed = _clamp01(1.0 - bid.estimated_time / TIME_NORM_SECONDS)
|
|
risk = _clamp01(1.0 - bid.risk_score)
|
|
load = _clamp01(1.0 - max(0, bid.current_load) / LOAD_NORM_SLOTS)
|
|
|
|
components = {
|
|
"confidence": round(policy.w_confidence * confidence, 6),
|
|
"capability": round(policy.w_capability * capability, 6),
|
|
"historical_success": round(policy.w_historical_success * tau, 6),
|
|
"budget": round(policy.w_budget * budget, 6),
|
|
"time": round(policy.w_time * speed, 6),
|
|
"risk": round(policy.w_risk * risk, 6),
|
|
"load": round(policy.w_load * load, 6),
|
|
}
|
|
total = round(sum(components.values()), 6)
|
|
return ArbitrationScore(agent_id=bid.agent_id, total=total, components=components)
|
|
|
|
|
|
def arbitrate(
|
|
bids: List[TaskBid],
|
|
policy: Optional[ArbitrationPolicy] = None,
|
|
*,
|
|
required_capabilities: Optional[List[str]] = None,
|
|
historical_success: Optional[Dict[str, float]] = None,
|
|
) -> TaskArbitrationResult:
|
|
"""Pick a winner from competing bids — deterministic and fully explainable.
|
|
|
|
Scoring: weighted sum of capability fit, historical_success (τ), self-confidence,
|
|
budget headroom, speed, inverse risk and inverse load (see `_score_bid`).
|
|
|
|
Determinism: bids are scored independently (no shared mutable state), then ordered by
|
|
(total DESC, agent_id ASC). The leading tuple is the winner. The agent_id tiebreak
|
|
removes any dependence on input order or dict iteration order, so the same inputs
|
|
always yield the same winner, the same scores and the same ordered losers.
|
|
|
|
The result records the human-readable reason (winning agent, its margin, dominant
|
|
component) and flags `decisive=False` when the top-two gap is below
|
|
`policy.decisive_margin`, so callers can route contested ties to Manager approval
|
|
instead of auto-assigning.
|
|
"""
|
|
policy = policy or ArbitrationPolicy()
|
|
required = required_capabilities or []
|
|
hist = historical_success or {}
|
|
|
|
task_id = bids[0].task_id if bids else ""
|
|
|
|
if not bids:
|
|
return TaskArbitrationResult(
|
|
task_id=task_id,
|
|
winner_agent_id=None,
|
|
reason="No bids submitted; nothing to arbitrate.",
|
|
decisive=False,
|
|
scores=[],
|
|
losers=[],
|
|
policy=policy,
|
|
)
|
|
|
|
scored: List[ArbitrationScore] = [
|
|
_score_bid(
|
|
bid,
|
|
required_capabilities=required,
|
|
historical_success=hist,
|
|
policy=policy,
|
|
)
|
|
for bid in bids
|
|
]
|
|
|
|
# Stable, documented ordering: higher total first; agent_id ascending breaks ties.
|
|
ranked: List[ArbitrationScore] = sorted(
|
|
scored, key=lambda s: (-s.total, s.agent_id)
|
|
)
|
|
|
|
winner = ranked[0]
|
|
runner_up_total = ranked[1].total if len(ranked) > 1 else None
|
|
margin = (winner.total - runner_up_total) if runner_up_total is not None else winner.total
|
|
decisive = margin >= policy.decisive_margin or len(ranked) == 1
|
|
|
|
dominant = max(winner.components.items(), key=lambda kv: (kv[1], kv[0]))[0]
|
|
if len(ranked) == 1:
|
|
reason = (
|
|
f"Sole bidder {winner.agent_id} wins task {task_id} uncontested "
|
|
f"(score={winner.total}, dominant factor={dominant})."
|
|
)
|
|
elif decisive:
|
|
reason = (
|
|
f"Agent {winner.agent_id} wins task {task_id} with score {winner.total} "
|
|
f"(margin {round(margin, 6)} over {ranked[1].agent_id}@{ranked[1].total}); "
|
|
f"dominant factor={dominant}."
|
|
)
|
|
else:
|
|
reason = (
|
|
f"Agent {winner.agent_id} narrowly leads task {task_id} "
|
|
f"(score {winner.total} vs {ranked[1].agent_id}@{ranked[1].total}, "
|
|
f"margin {round(margin, 6)} < decisive_margin {policy.decisive_margin}); "
|
|
f"contested — recommend Manager review before assignment."
|
|
)
|
|
|
|
losers = [s.agent_id for s in ranked[1:]]
|
|
|
|
logger.debug(
|
|
"arbitrate task=%s winner=%s decisive=%s losers=%s",
|
|
task_id, winner.agent_id, decisive, losers,
|
|
)
|
|
return TaskArbitrationResult(
|
|
task_id=task_id,
|
|
winner_agent_id=winner.agent_id,
|
|
reason=reason,
|
|
decisive=decisive,
|
|
scores=ranked,
|
|
losers=losers,
|
|
policy=policy,
|
|
)
|
|
|
|
|
|
# --- event payload builders --------------------------------------------------
|
|
# These build the PAYLOAD dicts only; they do NOT emit. Wiring them onto
|
|
# swarm_runtime.emit_event(run, event_type, ..., payload=...) is documented (not done)
|
|
# in the companion doc's integration notes. Shapes mirror existing HM event payloads
|
|
# (task_id + agent role/id + a human summary) so they slot into the contract cleanly.
|
|
def bid_submitted_event(bid: TaskBid) -> Tuple[str, Dict]:
|
|
"""task.bid_submitted — one agent has entered a bid."""
|
|
return "task.bid_submitted", {
|
|
"task_id": bid.task_id,
|
|
"agent_id": bid.agent_id,
|
|
"confidence": bid.confidence,
|
|
"estimated_cost": bid.estimated_cost,
|
|
"estimated_time": bid.estimated_time,
|
|
"risk_score": bid.risk_score,
|
|
"current_load": bid.current_load,
|
|
"capabilities": bid.capabilities,
|
|
"summary": bid.reason or f"Agent {bid.agent_id} bid for task {bid.task_id}",
|
|
}
|
|
|
|
|
|
def yielded_event(yield_msg: TaskYield) -> Tuple[str, Dict]:
|
|
"""task.yielded — an agent has released a task with a reason."""
|
|
return "task.yielded", {
|
|
"task_id": yield_msg.task_id,
|
|
"agent_id": yield_msg.agent_id,
|
|
"reason": yield_msg.release_with_reason,
|
|
"recommend_agent": yield_msg.recommend_agent,
|
|
"summary": (
|
|
f"Agent {yield_msg.agent_id} yielded task {yield_msg.task_id}: "
|
|
f"{yield_msg.release_with_reason}"
|
|
),
|
|
}
|
|
|
|
|
|
def takeover_requested_event(req: TaskTakeoverRequest) -> Tuple[str, Dict]:
|
|
"""task.takeover_requested — an agent asks to take a task from the current holder."""
|
|
return "task.takeover_requested", {
|
|
"task_id": req.task_id,
|
|
"requesting_agent_id": req.requesting_agent_id,
|
|
"current_agent_id": req.current_agent_id,
|
|
"reason": req.reason,
|
|
"summary": (
|
|
f"Agent {req.requesting_agent_id} requests takeover of task {req.task_id}"
|
|
+ (f" from {req.current_agent_id}" if req.current_agent_id else "")
|
|
),
|
|
}
|
|
|
|
|
|
def arbitrated_event(result: TaskArbitrationResult) -> Tuple[str, Dict]:
|
|
"""task.arbitrated — the arbitrator has chosen a winner; carries the full audit trail."""
|
|
return "task.arbitrated", {
|
|
"task_id": result.task_id,
|
|
"winner_agent_id": result.winner_agent_id,
|
|
"decisive": result.decisive,
|
|
"reason": result.reason,
|
|
"losers": result.losers,
|
|
"scores": [s.model_dump() for s in result.scores],
|
|
"summary": result.reason,
|
|
}
|