Files
Agentswarm/orchestrator/decision_engine.py
T
Songhaoz666andClaude Opus 4.8 a289495823 复审整改(PR #24):沙箱 fail-closed 隔离门控 + 可回放 DecisionTrace
回应 Fasthei 的 Request changes 两个阻塞项:

1) 安全 / fail-closed 沙箱隔离(原仅靠 ENABLE_QUALITY_EVAL + 运维约定):
   - 新增第二道显式确认 HEICODE_SANDBOX_ISOLATED(断言运行在隔离 Pod 内)。
   - sandbox.run_tests() 与 quality.evaluate_run_quality() 执行任何代码前调用
     assert_isolated(),未确认即抛 SandboxIsolationError——不写文件、不起子进程。
   - 启动期 assert_quality_eval_safe():ENABLE_QUALITY_EVAL 开但隔离未确认 → 拒绝启动
     (平台级硬失败,非运维口头约定)。
   - 文档(security-boundary §8.1/§9、CLAUDE.md)与测试同步:test-sandbox/test-quality
     先断言未确认时硬失败,再显式确认后继续。

2) #10 DecisionTrace 可回放(原仅存被选中任务的标量):
   - Decision 现记录完整重放上下文:整个候选集(每候选 tau/eta/weight/p_norm/dependents)、
     alpha/beta/epsilon、seed、free_slots、total_weight、explore_draw、select_pick、
     select_index、explored 分支。
   - 新增 DecisionEngine.replay_decision(trace):仅凭一条 trace(无 RNG/活体状态)复现被选任务;
     test-decision-engine 断言「重放==实选」跨 50 次决策(探索+利用)成立。
   - decision-engine.md §3.3 更新为可回放 DecisionTrace。

附:新增 docs/TESTING.md(reviewer 速查:依赖安装 + 每套测试命令,复审者此前因缺 fakeredis
未能跑到断言)。本地 11 项 gate 全绿。

影响范围:Swarm(orchestrator + 测试 + 文档)。不改 Manager↔Swarm 契约;新增开关
HEICODE_SANDBOX_ISOLATED(默认未设=拒绝执行)。仍非验收:gain/Benchmark_Agent 仍 NaN。

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

293 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ACO decision engine (benchmark Group A, Option A: score-at-pull).
Implements the standard's §3 decision layer over the EXISTING pull dispatch:
when an idle agent asks for work, score all eligible ready tasks with
P_i = τ_i^α·η_i^β / Σ and SAMPLE one (ε-greedy exploration), instead of taking
the first match. The agent side of the pairing is fixed by arrival order — this
is the documented one-sided limitation of Option A (full two-sided matching =
Option B, deferred until Group C can measure whether it helps).
Two independently-switched halves:
- LEARNING (pheromone deposits) is ALWAYS ON: every task terminal state updates
the trail. Pure passive observation — flag-off dispatch behavior is unchanged,
but history accrues so enabling the flag later acts on earned reputation, not
the cold-start prior.
- ACTING (probabilistic selection) is gated by ENABLE_ACO_DISPATCH (default OFF).
Honesty rules baked in (org rule #9 — no fabricated signals):
- τ deposit inputs we DON'T have contribute 0, never a made-up value:
acceptance (no acceptance signal), time (no per-task target), risk/rollback
(no signals). quality reuses success until per-task grading exists (run-level
fixture grading happens AFTER completion, too late for the deposit).
- η inputs without a source are a documented neutral constant (confidence=0.5,
identical for every candidate → no ranking distortion) or 0 (risk,
budget_pressure when unknown).
Determinism: the RNG is seedable via ACO_SEED so CI can assert exact behavior;
production leaves it unseeded.
"""
from __future__ import annotations
import json
import logging
import os
import random
import time
from dataclasses import dataclass
from typing import Dict, List, Optional
from benchmark.metrics import THETA_DEFAULTS, heuristic, p_decision, pheromone
from .redis_client import redis_client
logger = logging.getLogger(__name__)
PHEROMONE_KEY_PREFIX = "pheromone:"
# Trail bounds: keep τ strictly positive (τ^α must stay well-defined) and capped
# (the standard's literal update τ←(1−ρ)τ+Δτ saturates; the cap is the saturation).
TAU_INITIAL = 0.5
TAU_MIN = 0.05
TAU_MAX = 1.0
ETA_MIN = 0.05
URGENCY_NORM_SECONDS = 300.0 # task age at which urgency saturates to 1.0
DEPENDENCY_NORM = 3.0 # dependent-count at which criticality saturates to 1.0
RESOURCE_NORM_SLOTS = 2.0 # free slots at which resource fit saturates to 1.0
CONFIDENCE_NEUTRAL = 0.5 # no confidence signal → same neutral for all candidates
MAX_RECORDED_DECISIONS = 1000 # cap on per-run decision telemetry
def aco_dispatch_enabled() -> bool:
return os.getenv("ENABLE_ACO_DISPATCH", "false").lower() in {"1", "true", "yes"}
def _hyper(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
except ValueError:
return default
@dataclass
class Decision:
"""A fully replayable decision trace for one sampled assignment (#10 DoD).
Carries not just the CHOSEN task's scores but the entire context needed to re-derive and
explain the choice WITHOUT the live RNG: the full candidate set (each candidate's τ/η/weight/
p_norm/dependents), the hyperparameters (α/β/ε), the seed, free_slots, the ε-greedy branch,
and the recorded RNG draws (explore_draw + the exploit pick or explore index).
`DecisionEngine.replay_decision(trace)` reconstructs the chosen task_id from this alone.
"""
# chosen assignment
task_id: str
agent_id: str
agent_role: str
tau: float
eta: float
p_norm: float
p_score: float # standard §3.3 P_decision = τ^α·η^β·100 (the reported metric)
# replay context
explored: bool # True when the ε-greedy branch picked uniformly
alpha: float
beta: float
epsilon: float
seed: Optional[int]
free_slots: int
total_weight: float
explore_draw: float # rng.random() compared against ε to pick the branch
select_pick: Optional[float] # exploit: rng.random()*total_weight (absolute); None if explored
select_index: int # resolved index into `candidates`
candidates: List[dict] # full set in evaluated order: task_id/agent_role/tau/eta/weight/p_norm/dependents
def telemetry(self) -> dict:
# Replay-critical fields (weights, select_pick, total_weight) kept full-precision; only
# human-display duplicates are rounded.
return {
"task_id": self.task_id,
"agent_id": self.agent_id,
"agent_role": self.agent_role,
"tau": round(self.tau, 6),
"eta": round(self.eta, 6),
"p_norm": round(self.p_norm, 6),
"p_score": round(self.p_score, 6),
"explored": self.explored,
"alpha": self.alpha,
"beta": self.beta,
"epsilon": self.epsilon,
"seed": self.seed,
"free_slots": self.free_slots,
"total_weight": self.total_weight,
"explore_draw": self.explore_draw,
"select_pick": self.select_pick,
"select_index": self.select_index,
"candidates": self.candidates,
}
class DecisionEngine:
"""Pheromone store + η scorer + ε-greedy probabilistic selection."""
def __init__(self, rng: Optional[random.Random] = None):
seed = os.getenv("ACO_SEED")
self.seed = int(seed) if seed else None
self.rng = rng or (random.Random(self.seed) if self.seed is not None else random.Random())
# --- pheromone trail (τ) -------------------------------------------------
@staticmethod
def _trail_key(agent_role: str, agent_id: str) -> str:
return f"{PHEROMONE_KEY_PREFIX}{agent_role}:{agent_id}"
async def get_tau(self, agent_role: str, agent_id: str) -> float:
raw = await redis_client.get(self._trail_key(agent_role, agent_id))
if not raw:
return TAU_INITIAL
try:
return float(json.loads(raw).get("tau", TAU_INITIAL))
except Exception:
return TAU_INITIAL
async def deposit(
self,
*,
agent_role: str,
agent_id: str,
success: bool,
cost_ratio: float = 0.0,
) -> float:
"""Update the trail after a task reaches a terminal state (always on).
Standard §3.1: τ(t+1) = (1−ρ)·τ(t) + Δτ, clamped to [TAU_MIN, TAU_MAX].
Δτ comes from metrics.pheromone() with only the signals we actually have:
success (1/0), quality (=success — see module docstring), cost (task cost
as a fraction of the run budget, 0 when unknown). Absent signals are 0.
"""
rho = _hyper("ACO_RHO", THETA_DEFAULTS["rho"])
s = 1.0 if success else 0.0
delta = pheromone(
success=s, quality=s, acceptance=0.0,
cost=max(0.0, min(1.0, cost_ratio)), time=0.0, risk=0.0, rollback=0.0,
)
old = await self.get_tau(agent_role, agent_id)
new = max(TAU_MIN, min(TAU_MAX, (1.0 - rho) * old + delta))
await redis_client.set(
self._trail_key(agent_role, agent_id),
json.dumps({"tau": new, "updated_at": time.time()}),
)
logger.debug("pheromone %s:%s %.3f -> %.3f (Δ=%.3f)", agent_role, agent_id, old, new, delta)
return new
# --- heuristic (η) -------------------------------------------------------
@staticmethod
def compute_eta(task, agent_capabilities: List[str], *, free_slots: int,
dependents_count: int, now: Optional[float] = None) -> float:
"""A-priori desirability of (agent, task) from signals we actually hold."""
now = now or time.time()
required = set(task.required_capabilities or [])
caps = set(agent_capabilities or [])
union = required | caps
match = (len(required & caps) / len(union)) if union else 1.0 # Jaccard: rewards focus
urgency = min(1.0, max(0.0, now - task.created_at) / URGENCY_NORM_SECONDS)
dependency = min(1.0, dependents_count / DEPENDENCY_NORM)
resource = min(1.0, max(0, free_slots) / RESOURCE_NORM_SLOTS)
eta = heuristic(
match=match, urgency=urgency, dependency=dependency, resource=resource,
confidence=CONFIDENCE_NEUTRAL, risk=0.0, budget_pressure=0.0,
)
return max(ETA_MIN, eta)
# --- selection (P) -------------------------------------------------------
async def select(self, agent_id: str, agent_capabilities: List[str],
candidates: List, *, free_slots: int,
dependents_counts: Dict[str, int]) -> Optional[Decision]:
"""Sample one task from the eligible candidates with P_i = τ^α·η^β / Σ.
ε-greedy: with probability ε pick uniformly (exploration keeps low-τ
agents able to rebuild their trail and prevents starvation).
"""
if not candidates:
return None
alpha = _hyper("ACO_ALPHA", THETA_DEFAULTS["alpha"])
beta = _hyper("ACO_BETA", THETA_DEFAULTS["beta"])
epsilon = _hyper("ACO_EPSILON", THETA_DEFAULTS["epsilon"])
scored = [] # (task, tau, eta, weight, dependents)
for task in candidates:
tau = await self.get_tau(task.agent_role, agent_id)
dependents = dependents_counts.get(task.task_id, 0)
eta = self.compute_eta(
task, agent_capabilities, free_slots=free_slots, dependents_count=dependents,
)
scored.append((task, tau, eta, (tau ** alpha) * (eta ** beta), dependents))
total = sum(s[3] for s in scored)
explore_draw = self.rng.random()
explored = explore_draw < epsilon
select_pick = None
if explored or total <= 0:
select_index = self.rng.randrange(len(scored))
else:
select_pick = self.rng.random() * total
cum = 0.0
select_index = len(scored) - 1
for i, s in enumerate(scored):
cum += s[3]
if select_pick <= cum:
select_index = i
break
task, tau, eta, weight, _dep = scored[select_index]
candidate_traces = [
{
"task_id": s[0].task_id,
"agent_role": s[0].agent_role,
"tau": s[1],
"eta": s[2],
"weight": s[3],
"p_norm": (s[3] / total) if total > 0 else 1.0 / len(scored),
"dependents": s[4],
}
for s in scored
]
return Decision(
task_id=task.task_id,
agent_id=agent_id,
agent_role=task.agent_role,
tau=tau,
eta=eta,
p_norm=(weight / total) if total > 0 else 1.0 / len(scored),
p_score=p_decision(tau, eta, alpha, beta),
explored=explored,
alpha=alpha,
beta=beta,
epsilon=epsilon,
seed=self.seed,
free_slots=free_slots,
total_weight=total,
explore_draw=explore_draw,
select_pick=select_pick,
select_index=select_index,
candidates=candidate_traces,
)
@staticmethod
def replay_decision(trace: dict) -> str:
"""Reconstruct the chosen task_id from a stored DecisionTrace — no RNG, no live state.
Proves the trace is sufficient to explain/replay the selection: same candidates + branch +
recorded draw → same choice. Mirrors select()'s logic exactly.
"""
cands = trace["candidates"]
if trace.get("explored") or trace.get("total_weight", 0.0) <= 0:
return cands[trace["select_index"]]["task_id"]
pick = trace["select_pick"]
cum = 0.0
for c in cands:
cum += c["weight"]
if pick <= cum:
return c["task_id"]
return cands[-1]["task_id"]
decision_engine = DecisionEngine()