agent-chief 注意力守门员
SmileLikeYe/agent-chief 把 agents、CI、RSS、heartbeats 等事件先送进本地判定漏斗,再决定中断、摘要、派发、记忆或丢弃。它值得拆的地方不是 UI,而是“便宜规则优先、LLM 最后兜底、所有判断可追踪”的源码组织。
它解决什么,凭什么值得拆
agent-chief 的核心不是再造一个聊天机器人,而是在 core/brain.py:115 到 core/brain.py:198 之间把事件标准化、场景识别、硬规则、相似度分类、LLM judge、持久化和异步执行串成一条可审计管线。这个设计让它像一个本地优先 chief of staff:先判断一件事是否值得打扰你,再决定丢弃、进入摘要、派给 agent 或升级为中断。
数据契约集中在 core/schema.py:19、core/schema.py:52 和 core/schema.py:68,分别定义 Event、Decision、Task。它没有把一切都塞进 prompt,而是让每一步产出结构化对象,再由 core/state.py:1 之后的状态层持久化,这就是它能 trace、eval、学习和回放的基础。
▸核心数据
三段式判定,先便宜再聪明
第一道过滤器在 core/scorer.py:72,先用静态策略干掉 muted topic、dedup、zero-information 和 quiet-hours。尤其 core/scorer.py:61 到 core/scorer.py:69 用正则加 embedding 双条件识别“all clear”心跳,这比单纯关键词过滤稳,因为安全报告里提到 all clear 也不该被误杀。
▸Stage 1 硬规则
72def stage1( 73 event, 74 *, 75 now: datetime, 76 policy: Policy, 77 quiet_hours: str = "23:00-08:00", 78 night_whitelist: list[str] | None = None, 79 recent_dedup_keys: frozenset[str] | set[str] = frozenset(), 80 embedder: Embedder = DEFAULT_EMBEDDER, 81) -> RuleHit | None: 82 """Stage-1 hard rules (SPEC §4.4). Returns a forced route, or None to continue.""" 83 night_whitelist = night_whitelist or [] 84 85 # Drop rules run before the quiet-hours digest rule: noise must die even at 86 # night, or it would resurface in the morning digest (see §4.7 events 1/24). 87 if policy.is_muted(event.topic): 88 return RuleHit("drop", "muted_topic", f"topic {event.topic} muted in POLICY.md") 89 90 if event.dedup_key and event.dedup_key in recent_dedup_keys: 91 return RuleHit("drop", "dedup", "duplicate of an event seen in the last 24h") 92 93 if is_zero_information(event.summary, embedder): 94 return RuleHit("drop", "zero_information", "zero-information template (empty report)") 95 96 if in_quiet_hours(now, quiet_hours) and not _topic_in_whitelist( 97 event.topic, night_whitelist 98 ): 99 return RuleHit("digest", "quiet_hours", f"quiet hours {quiet_hours}, topic not whitelisted") 100 101 rule = policy.route_for(event.topic) 102 if rule: 103 return RuleHit( 104 rule.route, f"policy:{rule.pattern}", f"POLICY.md rule {rule.pattern} -> {rule.route}" 105 ) 106 107 return None
在 core/scorer.py:87 到 core/scorer.py:99 里,muted、dedup、zero_information、quiet_hours 的顺序不是随意的:先把无信息噪声杀掉,避免它们进入 morning digest;再处理夜间白名单,保证生产事故或家庭类事件仍能穿透。
▸Stage 2 相似度分类
123class SimilarityClassifier: 124 """Compares an event against engaged/dismissed historical vectors (ms). 125 126 dismissed-sim > 0.88 with no engaged record → drop; 127 engaged-sim > 0.88 → skip judge, route by historical same-class mean; 128 otherwise → pass through to stage 3. 129 """ 130 131 def __init__(self, embedder: Embedder = DEFAULT_EMBEDDER, threshold: float = STAGE2_THRESHOLD): 132 self.embedder = embedder 133 self.threshold = threshold 134 self._engaged: list[tuple[list[float], str]] = [] # (vector, route) 135 self._dismissed: list[list[float]] = [] 136 137 def add_engaged(self, text: str, route: str) -> None: 138 self._engaged.append((self.embedder.embed(text), route)) 139 140 def add_dismissed(self, text: str) -> None: 141 self._dismissed.append(self.embedder.embed(text)) 142 143 def classify(self, text: str) -> Stage2Verdict: 144 vec = self.embedder.embed(text) 145 engaged_hits = [ 146 (cosine(vec, evec), route) 147 for evec, route in self._engaged 148 if cosine(vec, evec) > self.threshold 149 ] 150 if engaged_hits: 151 # "route by historical same-class mean": majority route among the 152 # similar engaged records, nearest-first on ties. 153 engaged_hits.sort(reverse=True) 154 routes = [route for _, route in engaged_hits] 155 best = max(set(routes), key=lambda r: (routes.count(r), -routes.index(r))) 156 sim = engaged_hits[0][0] 157 return Stage2Verdict( 158 "route", best, sim, f"engaged-similar {sim:.2f}, historical route {best}" 159 ) 160 dismissed_sim = max((cosine(vec, dvec) for dvec in self._dismissed), default=0.0) 161 if dismissed_sim > self.threshold: 162 return Stage2Verdict( 163 "drop", None, dismissed_sim, 164 f"dismissed-similar {dismissed_sim:.2f} with no engaged record", 165 ) 166 return Stage2Verdict("pass", None, dismissed_sim, "unfamiliar; ask the judge")
Stage 2 的亮点在 core/scorer.py:145 到 core/scorer.py:166:engaged 相似就复用历史 route,dismissed 相似就 drop,否则才让 judge 介入。它把“我以前已经表达过态度”的事件从昂贵判断中拿出来。
▸深度阅读入口
深度阅读 · routing pipelineBrain loop 如何把一次事件变成 Decision
相似度分类器从 core/scorer.py:123 开始保存 engaged 和 dismissed 向量,core/scorer.py:143 到 core/scorer.py:166 决定是直接 drop、复用历史 route,还是交给 judge。这个中间层减少了 LLM 调用次数,也把常见噪声压在毫秒级。
▸process 主流程
115 async def process(self, payload: dict | Event) -> Decision: 116 from ingest.normalize import normalize 117 118 now = self.now_fn() 119 clock = _StageClock() 120 if isinstance(payload, Event): 121 event = payload 122 else: 123 event = await normalize(payload, inferrer=self.inferrer, now=now) 124 clock.mark("normalize") 125 126 policy = load_policy(self.policy_path) 127 scene = self._scene(now) 128 recent_keys = await self.state.recent_dedup_keys(now - timedelta(hours=24)) 129 130 # Triage merge (SPEC §4.2 step 1): a same-topic near-duplicate within 131 # 10 min folds into the earlier event; the earlier decision stands. 132 if self.embedder: 133 recent = await self.state.recent_events(now - timedelta(minutes=10)) 134 prior = find_mergeable(event, [e for e in recent if e.id != event.id], 135 embedder=self.embedder) 136 clock.mark("triage_merge", note="merged into prior" if prior else "no near-duplicate") 137 if prior: 138 merged = merge_events(prior, event) 139 await self.state.save_event(merged) 140 existing = await self.state.load_decision(prior.id) 141 if existing: 142 # the prior decision (and its trace) stands for the merged event 143 return existing.model_copy( 144 update={"reason": existing.reason + "; merged near-duplicate"} 145 ) 146 147 hit = stage1( 148 event, 149 now=now, 150 policy=policy, 151 quiet_hours=self.quiet_hours, 152 night_whitelist=self.night_whitelist, 153 recent_dedup_keys=recent_keys, 154 ) 155 if hit: 156 clock.mark("stage1", note=f"rule {hit.rule} fired") 157 decision = Decision( 158 event_id=event.id, 159 route=hit.route, # type: ignore[arg-type] 160 scene=scene.scene, 161 scene_confidence=scene.confidence, 162 cost=0.0, 163 matched_rules=[hit.rule], 164 reason=hit.reason, 165 stage=1, 166 trace=DecisionTrace(stages=clock.stages), 167 ) 168 else: 169 clock.mark("stage1", note="no hard rule fired") 170 decision = await self._stage2_or_judge(event, scene, policy, now, clock)
从 core/brain.py:126 到 core/brain.py:154,Brain 先加载策略、场景和 24 小时 dedup key,然后再进入 stage1。注意 core/brain.py:130 到 core/brain.py:145 的 triage merge,它让 10 分钟内同主题近重复事件合并,避免同一个告警换个措辞就重复触发。
▸judge 降级策略
247 try: 248 result = await asyncio.wait_for( 249 self.judge.judge(event, context), self.judge_timeout 250 ) 251 except Exception as exc: # malformed output, timeout, backend down — never crash 252 logger.warning("judge %s unavailable (%s); degrading to rules-only", backend, exc) 253 last_error = f"{type(exc).__name__}: {exc}"[:200] 254 if self._degraded is not True or last_error != self._last_error: 255 # transition or changed failure mode: refresh the marker, but 256 # preserve `since` from any still-active record (incl. one 257 # written before a daemon restart mid-outage) 258 current = await load_degraded(self.state) 259 await self.state.set_meta( 260 DEGRADED_KEY, 261 { 262 "active": True, 263 "since": current["since"] if current else now.isoformat(), 264 "last_error": last_error, 265 }, 266 ) 267 self._degraded, self._last_error = True, last_error 268 clock.mark("judge", note=f"FAILED: {type(exc).__name__}") 269 # retries that failed were still paid for — bill them (JudgeError.usage) 270 usage = getattr(exc, "usage", None) 271 return Decision( 272 event_id=event.id, 273 route="digest", # conservative: never interrupt, never drop, while blind 274 scene=scene.scene, 275 scene_confidence=scene.confidence, 276 cost=self._bill(backend, usage), 277 reason=f"judge unavailable ({type(exc).__name__}); " 278 "conservative rules-only routing to digest", 279 stage=3, 280 degraded=True, 281 trace=self._usage_trace(clock, backend, usage), 282 )
真正进入 judge 后,core/brain.py:247 到 core/brain.py:282 处理了超时、后端异常和降级:当 judge 不可用,它保守地送 digest,而不是盲目 interrupt 或 drop。这个失败策略很重要,因为注意力系统宁愿少叫你,也不能在 blind 状态下乱叫。
▸深度阅读入口
深度阅读 · brain loopscore × scene 阈值,决定是否打扰
路由合成在 core/scorer.py:207 到 core/scorer.py:256:五个维度加权、场景阈值、全局学习调整、低置信场景降级,以及 dispatchable prep work。这个函数是 agent-chief 的决策阀门,读懂它就读懂了“为什么这个事件此刻该不该打扰你”。
▸score_and_route
207def score_and_route( 208 result, 209 scene: SceneState, 210 *, 211 topic_weights: dict[str, float] | None = None, 212 scene_cost: float = 0.0, 213 threshold_overrides: dict[str, float] | None = None, 214 threshold_adjust: float = 0.0, 215 memory_hit: bool = False, 216) -> tuple[str, float, dict[str, float], str]: 217 """Compose `score = Σ(w_topic[dim]·comp[dim]) − scene_cost` and route. 218 219 Returns (route, score, components-after-boost, reason). 220 """ 221 from context.infer import downgrade_low_confidence, interrupt_threshold 222 223 weights = {**DEFAULT_WEIGHTS, **(topic_weights or {})} 224 comps = {dim: getattr(result, dim) for dim in DIMS} 225 if memory_hit: 226 comps["relevance"] = min(1.0, comps["relevance"] * 1.2) 227 228 score = sum(weights[dim] * comps[dim] for dim in DIMS) - scene_cost 229 threshold = interrupt_threshold(scene.scene, threshold_overrides) 230 if threshold_adjust: 231 # learned global adjustment, clamped (SPEC §4.6 bounds [0.35, 0.95]) 232 threshold = min(0.95, max(0.35, threshold + threshold_adjust)) 233 234 if score >= threshold: 235 route = "interrupt" 236 reason = f"score {score:.2f} ≥ {scene.scene} threshold {threshold:.2f}" 237 elif score >= DIGEST_FLOOR: 238 route = "digest" 239 reason = f"score {score:.2f} below {scene.scene} threshold {threshold:.2f}" 240 elif result.memorize: 241 route = "curate" 242 reason = f"score {score:.2f} low but worth remembering" 243 else: 244 route = "drop" 245 reason = f"score {score:.2f} with no lasting value" 246 247 route = downgrade_low_confidence(route, scene) 248 if route == "digest" and score >= threshold: 249 reason += f"; downgraded (scene confidence {scene.confidence:.2f} < 0.6)" 250 251 # SPEC §4.4: dispatchable prep work runs first, then delivery ("arrive with a plan") 252 if result.dispatchable and route in ("interrupt", "digest"): 253 route = "dispatch" 254 reason += "; dispatchable prep work available" 255 256 return route, score, comps, reason
值得注意的是 core/scorer.py:225 到 core/scorer.py:233:memory hit 会提升 relevance,学习到的 threshold_adjust 被夹在 0.35 到 0.95。也就是说,个性化会影响阈值,但不会把系统推到无限宽松或无限苛刻。
▸数据模型
19class Event(BaseModel): 20 id: str 21 source: str 22 topic: str 23 summary: str = Field(max_length=200) 24 detail: str | None = None 25 suggested_action: str | None = None 26 evidence: list[str] = [] 27 claimed_urgency: Literal["low", "medium", "high"] | None = None 28 expires_at: datetime | None = None 29 dedup_key: str | None = None 30 received_at: datetime 31 32 33class StageTiming(BaseModel): 34 """One pipeline stage in a decision trace (SPEC v3.1 Step 26).""" 35 36 stage: str # triage_merge | stage1 | stage2 | associate | judge | route 37 ms: float 38 note: str = "" 39 40 41class DecisionTrace(BaseModel): 42 """Per-decision latency, token and cost accounting (SPEC v3.1 Step 26).""" 43 44 stages: list[StageTiming] = [] 45 tokens_in: int = 0 46 tokens_out: int = 0 47 cached_tokens: int = 0 48 backend: str | None = None 49 prompt_version: str | None = None 50 51 52class Decision(BaseModel): 53 event_id: str 54 route: Route 55 score: float | None = None 56 components: dict[str, float] | None = None 57 scene: str 58 scene_confidence: float 59 cost: float # USD cost of this judgment (0.0 when no LLM was consulted) 60 matched_rules: list[str] = [] 61 reason: str 62 stage: int 63 dispatch_task_id: str | None = None 64 trace: DecisionTrace | None = None 65 degraded: bool = False # judge unavailable → rules-only conservative routing (Step 28)
Decision 在 core/schema.py:52 到 core/schema.py:65 保存 route、score、components、scene、cost、matched_rules 和 trace。这个对象让 `chief trace` 能解释一次判断,而不是只留下一个不可追溯的自然语言结论。
记忆不是 RAG 巨塔,而是偏好证据
记忆层很薄但边界清楚:memory/store.py:20 负责 curate,memory/store.py:34 负责 top-k associate,memory/store.py:54 负责 TTL 过期归档。它没有做复杂 RAG,而是把偏好和历史事实作为 judge 上下文的补充证据。
▸MemoryStore
15class MemoryStore: 16 def __init__(self, state: State, embedder: Embedder = DEFAULT_EMBEDDER): 17 self.state = state 18 self.embedder = embedder 19 20 async def curate( 21 self, text: str, *, topic: str, origin_event_id: str | None, now: datetime 22 ) -> MemoryItem: 23 item = MemoryItem( 24 id=f"mem_{now:%Y%m%d}_{secrets.token_hex(3)}", 25 origin_event_id=origin_event_id, 26 text=text, 27 topic=topic, 28 embedding=self.embedder.embed(text), 29 created_at=now, 30 ) 31 await self.state.save_memory(item) 32 return item 33 34 async def associate(self, text: str, *, now: datetime) -> list[MemoryItem]: 35 """Top-3 active memories with cosine > 0.78; updates hit stats (SPEC §4.2).""" 36 query = self.embedder.embed(text) 37 scored = [ 38 (cosine(query, item.embedding), item) 39 for item in await self.state.list_memory() 40 ] 41 hits = sorted( 42 (pair for pair in scored if pair[0] > ASSOCIATION_THRESHOLD), 43 key=lambda pair: pair[0], 44 reverse=True, 45 )[:TOP_K] 46 results = [] 47 for _sim, item in hits: 48 item.hit_count += 1 49 item.last_hit_at = now 50 await self.state.save_memory(item) 51 results.append(item) 52 return results 53 54 async def expire(self, *, now: datetime) -> int: 55 """Move items past their ttl_days into the archive table (SPEC §3).""" 56 moved = 0 57 for item in await self.state.list_memory(): 58 if now - item.created_at > timedelta(days=item.ttl_days): 59 await self.state.save_memory(item, archive=True) 60 await self.state.delete_memory(item.id) 61 moved += 1 62 return moved
MemoryStore 的 associate 在 memory/store.py:34 到 memory/store.py:52 只取 cosine 大于 0.78 的 top 3,并更新 hit_count 与 last_hit_at。这个约束让记忆像判定证据,而不是把所有旧文本都塞进 prompt。
▸深度阅读入口
深度阅读 · memory learning“完成”必须被验证
派发层把“agent 做完了”当成一个需要证明的声明。dispatch/acceptance.py:30 到 dispatch/acceptance.py:54 先跑 acceptance_cmd,没有命令就用二次判定;dispatch/acceptance.py:57 到 dispatch/acceptance.py:88 明确最多重试一次,失败后找人接手。
▸acceptance verifier
30async def verify( 31 task: Task, *, ask: AskFn | None = None, exec_fn: Callable = _default_exec 32) -> VerifyResult: 33 """Check a completed task against its acceptance criteria.""" 34 if task.acceptance_cmd: 35 argv = shlex.split(task.acceptance_cmd) 36 code, _out, err = await exec_fn(argv, None) 37 if code == 0: 38 return VerifyResult(True, "acceptance_cmd exit 0") 39 return VerifyResult(False, f"acceptance_cmd exit {code}: {err.strip()[:120]}") 40 41 if ask is None: 42 # No verifier available: never bless unverified work. 43 return VerifyResult(False, "no verifier configured; refusing to trust the claim") 44 45 prompt = verify_prompt( 46 acceptance=task.acceptance, result=task.result_summary or "(no result)" 47 ) 48 raw = await ask(prompt) 49 try: 50 data = json.loads(extract_json(raw)) 51 return VerifyResult(data.get("verdict") == "pass", data.get("reason", "")) 52 except Exception: 53 logger.warning("verifier returned garbage; treating as fail: %r", raw[:80]) 54 return VerifyResult(False, "verifier output unparseable") 55 56 57async def dispatch_and_verify( 58 state: State, 59 task: Task, 60 executor: Executor, 61 *, 62 ask: AskFn | None = None, 63 exec_fn: Callable = _default_exec, 64) -> tuple[Task, str | None]: 65 """Run + verify with one retry (attempts max 2, SPEC §3); on final failure 66 mark the task rejected and return a message asking the human.""" 67 last_reason = "" 68 while task.attempts < 2: 69 task = await run_task(state, task, executor) 70 if task.status == "done": 71 result = await verify(task, ask=ask, exec_fn=exec_fn) 72 if result.passed: 73 return task, None 74 last_reason = result.reason 75 task.status = "failed" 76 await state.save_task(task) 77 else: 78 last_reason = task.result_summary or "executor failed" 79 80 task.status = "rejected" 81 await state.save_task(task) 82 ask_human = ( 83 f"I tried twice and couldn't finish this myself: {task.goal}\n" 84 f"Last attempt: {task.result_summary}\n" 85 f"Why it didn't pass: {last_reason}\n" 86 f"Can you take a look?" 87 ) 88 return task, ask_human
派发失败路径在 dispatch/acceptance.py:68 到 dispatch/acceptance.py:88 很清楚:最多两次尝试,仍不通过就把 Task 标为 rejected,并生成一条请求人类介入的消息。这个设计避免 agent-chief 把未验证结果包装成成功。
边界风险与可继续观察的点
▸风险 1:判定阈值的可迁移性
Stage 1 与 Stage 2 的参数在 core/scorer.py:31、core/scorer.py:112 和 core/scorer.py:171 固化得很清楚,但不同用户的噪声分布差异很大。仓库用 eval 证明默认值可用,生产接入时仍应先跑 shadow mode。
▸风险 2:派发执行器权限
当 route 变成 dispatch,core/brain.py:304 到 core/brain.py:314 会创建 Task。真正风险在 executor 能触碰什么系统,所以 acceptance_cmd、sandbox 和审计日志必须一起看。
▸风险 3:记忆污染
记忆入口在 core/brain.py:299 到 core/brain.py:302,如果 judge 的 memorize 字段被提示注入污染,错误偏好可能长期影响 relevance。这里需要靠 policy、eval 和用户反馈修正。