Files
Agentswarm/orchestrator/task_competition.py
T
Songhaoz666andClaude Opus 4.8 f2662e4268 复审整改(PR #26):删死开关 helper、文档对齐"无条件"、swarm.health 改内部、#6 降为 Refs
回应 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>
2026-06-10 16:48:27 +08:00

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,
}