Files
Agentswarm/scripts/test-dispatch-scored.py
T
Songhaoz666andClaude Opus 4.8 62610a7e3f 调度评分:多维可解释打分匹配 + dispatch.decision_made(Closes #9)
把派发从「能力子集 + 空闲」升级为任务为中心的多维可解释打分匹配。

新增/改动:
- orchestrator/dispatch_score.py(新):DispatchCandidate/DispatchScore + 加权掩码归一
  打分(score_candidate/rank_candidates)+ build_dispatch_decision_event。
- orchestrator/main.py:抽出三模式共用的 finalize_dispatch;新增 scored_matchmake
  (ENABLE_DISPATCH_SCORE,默认关,与 ACO 择一)——为每个就绪任务在有能力的空闲 Agent
  间按 capability/历史成功(τ)/负载/预算压力/权限择优,记录可解释决策;_run_budget_pressure
  计算真实预算占比。
- orchestrator/swarm_runtime.py:SwarmRun.dispatch_decisions + record_dispatch_decision
  (内部状态,非 Manager 事件)。
- 测试:scripts/test-dispatch-score.py(公式 24 项)+ scripts/test-dispatch-scored.py
  (集成 11 项:按 τ/负载多 Agent 择优 + 排除原因 + 可回放记录);CI 纳入两者。
- docs/scheduling/dispatch-score-schema.md、CLAUDE.md 同步。

诚实边界:risk_score/estimated_cost/estimated_time 本仓无来源 → None 并在 payload
uncollected_dimensions 披露(不伪造,规则 #9);dispatch.decision_made 暂为 Swarm 内部
记录,未进 Manager 事件契约(需 event-schema 注册,跨端)。flag 关闭时贪心/ACO 路径逐字节不变。

Closes #9

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:08:08 +08:00

118 lines
5.3 KiB
Python

"""Integration test for issue #9: task-centric SCORED dispatch picks among AGENTS.
Exercises orchestrator.main.scored_matchmake against the real task_queue + agent_registry +
decision_engine (REDIS_FAKE, no WS/model): two capable agents differ in historical success (τ)
and load; the higher-scored agent is chosen; an incapable agent is excluded; the explainable
dispatch.decision_made record (candidate breakdown + exclusion reasons + ≥4 non-capability
dimensions with real signals) is produced and stored on the run.
Run from agent_swarm_v6 (install deps first — needs fakeredis):
pip install -r orchestrator/requirements.txt -r agent/requirements.txt
REDIS_FAKE=1 python scripts/test-dispatch-scored.py
"""
import asyncio
import os
import sys
from pathlib import Path
os.environ["REDIS_FAKE"] = "1"
os.environ["ENABLE_DISPATCH_SCORE"] = "1"
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from orchestrator.redis_client import redis_client
from orchestrator import swarm_runtime as sr_mod
from orchestrator.swarm_runtime import swarm_runtime
from orchestrator.task_queue import task_queue, TaskStatus
from orchestrator.agent_registry import agent_registry
from orchestrator.decision_engine import decision_engine
from orchestrator import main as orch
failures = []
def check(name, cond):
print(("PASS" if cond else "FAIL"), "-", name)
if not cond:
failures.append(name)
async def _noop(self, *a, **k):
return None
async def main():
await redis_client.connect()
sr_mod.SwarmRuntime._post_callback = _noop
body = {
"mode": "swarm",
"requirement": {"objective": "scored dispatch test"},
"orchestration_plan": {"budget": {"max_cost_usd": 10}},
"callback": {"url": "http://127.0.0.1:9/cb", "subscribed_events": []},
"metadata": {"manager_deployment_id": "m-disp"},
}
run, _ = await swarm_runtime.get_or_create_run(body=body, idempotency_key=None, correlation_id="cd")
# One ready task requiring python.
task = await task_queue.create_task(task_id="t-implementation", description="impl",
agent_role="implementation",
required_capabilities=["python"], enqueue=True)
await swarm_runtime.attach_task(run, task.task_id)
# Three agents: A & B both capable (python); C incapable (java only).
await agent_registry.register_agent("agent-A", ["python"])
await agent_registry.register_agent("agent-B", ["python"])
await agent_registry.register_agent("agent-C", ["java"])
orch.AGENT_SLOTS.update({"agent-A": 1, "agent-B": 1, "agent-C": 1})
# Historical success (τ) differs: A strong, B weak — same capability & load otherwise.
for _ in range(8):
await decision_engine.deposit(agent_role="implementation", agent_id="agent-A", success=True)
await decision_engine.deposit(agent_role="implementation", agent_id="agent-B", success=False)
idle = [a for a in await agent_registry.get_idle_agents()
if a.agent_id in {"agent-A", "agent-B", "agent-C"}]
assignments = await orch.scored_matchmake(idle)
check("exactly one assignment produced", len(assignments) == 1)
agent, chosen_task, event = assignments[0]
check("higher-τ agent A chosen over B (historical_success decides)", agent.agent_id == "agent-A")
check("chosen task is the python task", chosen_task.task_id == "t-implementation")
# Explainable event payload
check("event names chosen agent/task",
event["chosen_agent_id"] == "agent-A" and event["chosen_task_id"] == "t-implementation")
check("incapable agent C excluded as capability_mismatch",
event["excluded"].get("agent-C") == "capability_mismatch")
check("loser B excluded as lower_score", event["excluded"].get("agent-B") == "lower_score")
a_cand = next(c for c in event["candidates"] if c["agent_id"] == "agent-A")
present = {k for k, v in a_cand["score"]["breakdown"].items() if v is not None}
noncap_present = present - {"capability_match"}
check("≥4 non-capability dimensions have real signals",
{"historical_success", "load", "permission_fit", "budget_pressure"} <= noncap_present
and len(noncap_present) >= 4)
check("uncollected dims disclosed (no fabrication)",
set(event["uncollected_dimensions"]) >= {"estimated_cost", "estimated_time", "risk_score"})
check("A's historical_success > B's in breakdown",
a_cand["score"]["breakdown"]["historical_success"]
> next(c for c in event["candidates"] if c["agent_id"] == "agent-B")["score"]["breakdown"]["historical_success"])
# finalize_dispatch stores the decision on the run (audit/benchmark-replayable, internal)
await task_queue.remove_pending_task(chosen_task.task_id)
ok = await orch.finalize_dispatch(agent, chosen_task, dispatch_event=event)
check("finalize_dispatch assigned the task", ok is True)
refreshed = await swarm_runtime.get_run(run.swarm_id)
check("dispatch decision recorded on run (replayable)",
len(refreshed.dispatch_decisions) == 1
and refreshed.dispatch_decisions[0]["chosen_agent_id"] == "agent-A")
print()
if failures:
print(f"{len(failures)} scored-dispatch check(s) FAILED: {failures}")
sys.exit(1)
print("all scored-dispatch (#9) checks passed")
if __name__ == "__main__":
asyncio.run(main())