B
BugTriager Agent
集成失败归因 · 4 类分类 · 提议 SDD 补丁 · confidence 评分
① 功能需求
- 定位 root_cause:用一句人话说清楚错在哪。禁止「测试失败」「需要修复」式废话。
- 4 类归因,必选其一:
contract_mismatch· SDD 双方契约不齐 → 必填proposed_sdd_patchimpl_bug· 代码没遵守 SDD → 不改 SDD,只 reset Unitspec_ambiguous· SDD 描述歧义 → 升 L3,等人infra_flake· 端口/进程/网络等环境问题 → 直接重跑
- 影响面:
affected_units只列需要重写的 Unit;牵连但不必动的不列。 - confidence 评分:0–1。
< 0.5自动升 L3,不允许 LoopController 自动 apply。 - Evidence 必须可验证:每条 evidence 来自原文件 grep,
extract_evidence工具校验,造假被拒。 - 不动任何文件除 triage.json。SDD 改由 Architect 在 patch 模式干。
- 不调 Architect / 其他 Agent。只产 triage.json,决定权交 LoopController。
② Skill 设计
2.1 输出沙箱(极小)
data/companies/{cid}/projects/{pid}/integration/runs/{runId}/
└── triage.json # 唯一写入文件 ★
# 只读 (无沙箱限制,需要看全部上下文做归因):
integration/runs/{runId}/result.json
integration/runs/{runId}/process.log
integration/runs/{runId}/http-trace.ndjson
units.json
sdd/mvcp/**.html
units/*/code/**
units/*/contract.json
units/*/coder.log # 看 Coder 之前怎么想的
units/*/runs/*/result.json # Tester 历史
bugs.ndjson # 查相似历史 bug
2.2 完整 System Prompt
落 system/templates/devloop-triager.md。LoopController 注入时头部补 ## Memory + ## Failure Context(含 result.json 节选 + 涉事 Unit 列表)。
# ROLE 你是 LemonBoss DevLoop 的 BugTriager Agent。本次任务:分析集成测试失败 {runId}。 读 Integrator 写的 result.json + process.log + http-trace,加上涉事 Unit 的 code/contract/coder.log,产出 triage.json,决定下一步动作。 你的归因是 L2 自愈的核心判断。错了会让项目陷入死循环或修错地方, 所以宁可输出低 confidence 让人介入,也不要硬猜。 # OUTPUT (唯一) integration/runs/{runId}/triage.json (M-9 schema) 字段: run_id 来自输入 root_cause string, ≥20 字, 含具体行为 category "contract_mismatch" | "impl_bug" | "spec_ambiguous" | "infra_flake" affected_units string[], 只列需要 reset 的 proposed_sdd_patch object | null (仅 contract_mismatch 必填) confidence number 0..1 evidence string[], 来自原文件的引文 (extract_evidence 校验) # MANDATORY WORKFLOW ## step 0 · 收集证据 - 读 result.json failures[0] 的 stack/got/expected/involves - 读 http-trace.ndjson 失败 case 完整调用链 - 读 process.log 失败时间窗口前后 50 行 (用 extract_evidence 工具) - 对每个 involves Unit, 读 contract.json + coder.log + sdd section ## step 1 · 写 todolist (4 项) step-001 收集 4 类证据 (Integrator/SDD/code/历史) step-002 用 category_classifier 做粗判 → 自己再审一遍 step-003 确定 affected_units (基于 involves + root_cause 精修) step-004 (如 contract_mismatch) 起草 proposed_sdd_patch step-005 写 triage.json + 自检 evidence grep-able # CATEGORY 判定流程图 start │ ▼ stderr 含 EADDRINUSE/ECONNREFUSED/timeout? ──yes──> infra_flake │ no ▼ SDD 中相关行为有明确规定? ──no──> spec_ambiguous (升 L3) │ yes ▼ code 实现与 SDD 描述一致? ──no──> impl_bug │ yes ▼ involves 中两个 Unit 对同一契约理解不一? ──yes──> contract_mismatch │ no ▼ unknown → 升 L3 (confidence ≤ 0.4) # CATEGORY 详细判定准则 [contract_mismatch] 特征: A Unit 的 contract.json 与 B Unit 的 contract.json (或 SDD) 对同一 endpoint 的 status/body 表达不一 典型: auth-be contract 写 "401 invalid_credentials" 但 SDD 描述写 "403 forbidden" auth-fe 期望 403,所以 401 时不工作 必填: proposed_sdd_patch (统一双方,通常修正 SDD) [impl_bug] 特征: SDD 明确写了 X,code 没遵守 (做了 Y 或漏做) 典型: SDD 写 "user 不存在返 404", code 写 throw new Error() proposed_sdd_patch: null (不改 SDD) affected_units: 仅那一个 [spec_ambiguous] 特征: SDD 没写、写得模糊、双解 典型: SDD 没规定 token 过期后是否自动刷新 proposed_sdd_patch: null (Architect 不该自己猜) confidence: 通常 ≤ 0.5 → 升 L3 [infra_flake] 特征: 端口冲突、网络超时、进程未起、磁盘满 典型: stderr 含 EADDRINUSE / ETIMEDOUT proposed_sdd_patch: null affected_units: [] (直接重跑就行) # RULES (硬约束) 1. 永远不动任何文件,除 triage.json。Architect 才有改 SDD 的权限。 2. root_cause 必须含具体行为。反例(禁): ✗ "测试失败" ✗ "auth-be 有问题" ✗ "需要修复" 正例: ✓ "auth-be 在 user 不存在时抛 TypeError 返 500, 但 auth-fe 期望 401 (SDD 规定)" 3. evidence 列表的每条必须能在原文件 grep 到。extract_evidence 工具校验, 命中失败 → 拒收 triage.json。 4. confidence: 0.9+ : 证据非常清晰 0.7-0.9: 主要证据齐,有少量推断 0.5-0.7: 几条解释都说得通,选一个最可能的 <0.5: 不确定,自动升 L3 (LoopController 不 auto-apply) 5. category=contract_mismatch 必填 proposed_sdd_patch, = impl_bug / spec_ambiguous / infra_flake 必为 null。 6. affected_units: contract_mismatch: 通常 ≥2 个 (双方都要重写) impl_bug: 通常 1 个 spec_ambiguous: [] (等 L3 决策后才知道) infra_flake: [] 7. 不调 call_sub_agent,不写 timeline (LoopController 接管后写)。 # SELF-CHECK 1. triage.json 通过 typebox (M-9) 2. root_cause ≥ 20 字 + 含具体行为词 (返/抛/收/写/读 之一) 3. evidence 每条用 extract_evidence 工具校验 → 全 ok=true 4. category 与 proposed_sdd_patch null 性匹配 (见 §RULES 5) 5. confidence 与 category 合理 (impl_bug 通常 >0.7, spec_ambiguous <0.6) 6. affected_units 引用都在 units.json 中
2.3 工具集
| 工具 | 签名 | 用途 | 访问 |
|---|---|---|---|
Read | (path) | 读全项目(只读) | 项目内 |
Grep | (pattern, path) | 在 code/ / log 中找证据 | 项目内 |
Write | (path, content) | 仅 triage.json | 白名单 |
extract_evidence NEW | (pid, evidence: string[]) → {ok, results[], failed[]} | 验证每条 evidence 是否能在 "{file_path}: 引文" 格式中 grep 命中 | — |
category_classifier NEW | (context) → {category, confidence, hints} | 启发式粗判(基于 stderr 关键词 + SDD 一致性扫描) | — |
diff_contract_vs_impl NEW | (pid, unitId) → {matches, divergences[]} | code 实际行为 vs contract.json 声明的差异 | — |
find_similar_bug NEW | (pid, signature) → Bug[] | 在 bugs.ndjson 中查相似历史,提升判断准度 | — |
write_todolist · update_todo_item · update_runtime_state · timeline_append · record_memory | 同上 | 持久化 | agent-state/ |
2.4 禁用
- 不允许 Edit / Write 任何文件 除 triage.json
- 不允许 Bash — 全 Read/Grep 即可,不需要跑命令
- 不允许 call_sub_agent
- 不允许造假 evidence — 必须能 grep 命中原文
- 不允许在 root_cause 用废话(「测试失败」等被自检拒)
③ 持久化集成
同模式但会话不复用(每次新归因独立)。memory 是 BugTriager 最有价值的累积资产,典型话题:
- "EADDRINUSE 永远是 infra_flake 不是 impl_bug"
- "401 vs 403:401 是凭据无效,403 是有凭据但权限不够"
- "impl_bug 出现 ≥2 次 → 升级为 spec_ambiguous(SDD 描述不够清晰)"
- "vitest 退出码 1 但 raw.json 空 → 测试框架本身崩 = infra_flake"
④ I/O 契约
const TriagerInput = Type.Object({ pid: Type.String(), run_id: Type.String(), // 哪一轮集成失败 mode: Type.Union([Type.Literal('triage'), Type.Literal('resume')]), resume_token: Type.Optional(Type.String()), call_id: Type.String(), company_id: Type.String(), }); const TriagerOutput = Type.Object({ ok: Type.Boolean(), // 总能产出,除非崩;ok=false 仅指 triage 本身有缺陷 run_id: Type.String(), triage_ref: Type.String(), // path to triage.json category: Type.String(), confidence: Type.Number(), affected_units: Type.Array(Type.String()), will_auto_apply: Type.Boolean(), // true 当 confidence≥0.5 且 category 允许 evidence_verified: Type.Boolean(), // 所有 evidence grep 命中 duration_ms: Type.Number(), tokens_used: Type.Number(), });
⑤ 上下游传递
⬆ 上游
- Integrator · result.json · process.log · http-trace.ndjson
- Architect(间接)· sdd/ · units.json · integration/spec.json
- 所有 Coder(间接)· code/** · contract.json · coder.log
- Tester(间接)· 历史 result.json
- BugTracker(间接)· bugs.ndjson 历史
- LoopController · TriagerInput
⬇ 下游
- LoopController · 读 triage.json 决定 ① apply patch 走 Architect ② 仅 reset Unit ③ 重跑 Integrator ④ 升 L3
- Architect(如果 will_auto_apply=true)· mode=patch + triage.json 输入
- UnitRegistry · reset affected_units
- Human (L3)(如 confidence<0.5)· SSE human_intervention_needed
- BugTracker(间接)· LoopController 改 bug.category 字段(unknown → 实际值)
⑥ 完整执行样例
# INPUT { "pid": "p-test001", "run_id": "run-int-20260513-1230", "mode": "triage", ... } # 读 result.json: # failure: case "login-then-profile" step 1 fail # involves: ["auth-be","profile-be"] # got: { status: 200, body: { user_id: 'demo' } } # expected: { status: 401 } # step 1 · 收证据 # 从 http-trace: # step 0: POST /api/auth/login → 200 { token: "expired-jwt..." } # (注意:token 是测试 fixture 写死,故意过期) # step 1: GET /api/profile/me with expired token → 200 ✗ 应 401 # 读 sdd/mvcp/control.html data-unit-id="profile-be": #... 401 if token expired ...# 读 profile-be/contract.json: # responses: { "200": ..., "404": ... } ← 没有 401! # 读 profile-be/code/server.js: # 没有 JWT 校验,直接信任 Authorization header,从 token 取 user_id # step 2 · category_classifier category_classifier(...) → { category: "contract_mismatch", confidence: 0.82 } // 但等等:这是 contract_mismatch 还是 impl_bug? // SDD 说要 401, contract.json 没说 → contract 与 SDD 不齐 → contract_mismatch // (而非 impl_bug:实现没遵守 contract 的话才是 impl_bug) # step 3 · affected_units = ["profile-be"] # auth-be 不动:它的契约本来就对 (返 token) # step 4 · 起草 patch proposed_sdd_patch = { target_section: "#unit-profile-be", op: "update_contract", text: "明确:接收 Authorization 头,验证 JWT,失败返 401 invalid_token" } # step 5 · 写 triage.json { "run_id": "run-int-20260513-1230", "root_cause": "profile-be 没校验 JWT 直接信任 Authorization 头,过期 token 收到 200 而非 SDD 规定的 401", "category": "contract_mismatch", "affected_units": ["profile-be"], "proposed_sdd_patch": { "target_section": "#unit-profile-be", "op": "update_contract", "text": "明确:验证 JWT,过期返 401 invalid_token" }, "confidence": 0.82, "evidence": [ "sdd/mvcp/control.html: '401 if token expired'", "profile-be/contract.json: responses 仅含 200/404,无 401", "profile-be/code/server.js:23: 无 jwt.verify 调用", "http-trace step 1: expired token returns 200 not 401" ] } # OUTPUT { "ok": true, "category": "contract_mismatch", "confidence": 0.82, "affected_units": ["profile-be"], "will_auto_apply": true, "evidence_verified": true, "duration_ms": 28_412, "tokens_used": 9_823 } # LoopController → callAgent('architect', { mode:'patch', triage:{...} })
# INPUT 同样,但实际场景不同 # 读发现: # sdd 明确写: "user 不存在返 404 with {error:'user_not_found'}" # contract.json 也写: responses: { "200": ..., "404": ... } ✓ # code/server.js: user 查不到时 throw new Error('not found') → 500 # 这是实现没遵守 SDD,不是契约不齐 # triage.json { "run_id": "...", "root_cause": "profile-be 在 user 不存在时抛 throw 错误返 500,SDD 已规定该返 404 user_not_found", "category": "impl_bug", // 不是 contract_mismatch "affected_units": ["profile-be"], "proposed_sdd_patch": null, // impl_bug 不改 SDD "confidence": 0.93, // 证据非常清晰 "evidence": [ "sdd/mvcp/control.html: '404 with {error:user_not_found}'", "profile-be/contract.json responses 404 已声明", "profile-be/code/server.js:41: throw new Error('not found')", "http-trace step 1: got 500" ] } # LoopController → 不改 SDD, 只 unitRegistry.reset(['profile-be']) # → L1 重跑 profile-be (Coder 反思: SDD 写 404 我做了 throw, 这次改)
# INPUT 同样 # 读发现: # 失败 case: token 过期 fe 应自动刷新, 但 SDD 没说 # sdd 关于 token 续期: 没找到 → 翻 prd: 也没说 # auth-fe/code: 实现了自动刷新, 但没说收到 401 后等多久重试 # auth-be/code: 接收 refresh_token 也行, 没接收也行 (双解) # 这种"双方都说得通,SDD 没规定"的情况 = spec_ambiguous # triage.json { "run_id": "...", "root_cause": "SDD 未规定 token 过期后 fe 是否自动刷新及刷新策略,auth-fe 实现刷新 1 次后放弃,auth-be 同时接收 refresh_token 与重新登录两种方式", "category": "spec_ambiguous", "affected_units": [], // 等人决策后才知道 "proposed_sdd_patch": null, "confidence": 0.42, // < 0.5 → 升 L3 "evidence": [ "sdd/mvcp/control.html: 搜 'refresh' 0 命中", "prd/product/scope.html: 同上 0 命中", "auth-fe/code/auth-service.js:67: setTimeout 重试 1 次", "auth-be/code/server.js:34: 接收 grant_type=refresh_token" ], "l3_question": "Token 过期后:(A) fe 自动刷新 ≤3 次 (B) 提示用户重新登录 (C) 静默续期不感知" } # LoopController: confidence<0.5 + category=spec_ambiguous # → SSE human_intervention_needed with l3_question # → 不动 Unit,不改 SDD,等人
⑦ 助手工具源码
7.1 extract-evidence.js · evidence 真伪验证
// server/services/agent-tools/extract-evidence.js · ~60 行 const fs = require('fs'); const path = require('path'); /** * 验证一条 evidence 是否能在 sources 中 grep 命中。 * evidence 格式约定: "{file_path}: {引文}" * 例如: "sdd/mvcp/control.html: '401 if token expired'" */ function verifyOne(pid, evidence) { const sep = evidence.indexOf(': '); if (sep < 0) return { ok: false, reason: 'malformed: need "path: quote"' }; const relPath = evidence.slice(0, sep).replace(/^['"]|['"]$/g, ''); const quote = evidence.slice(sep + 2).replace(/^['"]|['"]$/g, ''); const abs = path.join(projectsDir(pid), relPath); if (!fs.existsSync(abs)) return { ok: false, reason: 'file not found: ' + relPath }; const content = fs.readFileSync(abs, 'utf8'); // 模糊匹配:去掉过多空白后 includes 即可 const norm = (s) => s.replace(/\s+/g, ' ').toLowerCase(); if (norm(content).includes(norm(quote))) return { ok: true, hit_file: relPath }; // 行号格式 "path:lineno: 引文" 也支持 const lineMatch = relPath.match(/^(.+):(\d+)$/); if (lineMatch) { const lines = content.split('\n'); const line = lines[parseInt(lineMatch[2],10)-1] || ''; if (norm(line).includes(norm(quote))) return { ok: true, hit_file: lineMatch[1], line: lineMatch[2] }; } return { ok: false, reason: 'quote not found in file', quote, file: relPath }; } function verifyAll(pid, evidenceList) { const results = evidenceList.map(e => ({ evidence: e, ...verifyOne(pid, e) })); return { ok: results.every(r => r.ok), results, failed: results.filter(r => !r.ok), }; } module.exports = { verifyOne, verifyAll };
7.2 category-classifier.js · 启发式粗判(Agent 还要审)
// server/services/agent-tools/category-classifier.js · ~80 行 const fs = require('fs'); const path = require('path'); function classify({ resultJson, processLog, sddText, codeTexts }) { const hints = []; let score = { contract_mismatch:0, impl_bug:0, spec_ambiguous:0, infra_flake:0 }; // 1) infra_flake 信号 (强) const infraSignals = [ /EADDRINUSE/, /ECONNREFUSED/, /ETIMEDOUT/, /EAI_AGAIN/, /Cannot find module/, /port already in use/i, /health check timeout/i, ]; if (infraSignals.some(re => re.test(processLog))) { score.infra_flake += 3; hints.push('infra signal in process.log'); } // 2) spec_ambiguous 信号 // 失败行为相关关键词在 sdd 中找不到 → 强信号 const failTokens = extractKeyTokens(resultJson); // "refresh", "token expire", etc const sddLc = sddText.toLowerCase(); const tokensNotInSdd = failTokens.filter(t => !sddLc.includes(t.toLowerCase())); if (tokensNotInSdd.length >= 2) { score.spec_ambiguous += 2; hints.push('key tokens not found in SDD: ' + tokensNotInSdd.join(',')); } // 3) contract_mismatch 信号: sdd 说一个 status, contract.json 说另一个 // (实现细节略) // 4) impl_bug 信号: sdd 明确 + contract 明确 + code 不符 // 用 5xx in got + 4xx in expected 是强信号 const got = resultJson?.failures?.[0]?.got; const expect = resultJson?.failures?.[0]?.expect; if (got?.status >= 500 && expect?.status >= 400 && expect?.status < 500) { score.impl_bug += 2; hints.push('5xx got vs 4xx expected → impl_bug'); } // 选最高分 const sorted = Object.entries(score).sort(([,a],[,b]) => b-a); const [topCat, topScore] = sorted[0]; // confidence 启发式 let confidence; if (topScore >= 3) confidence = 0.85; else if (topScore === 2) confidence = 0.65; else if (topScore === 1) confidence = 0.45; else confidence = 0.30; return { category: topCat, confidence, hints, score }; } function extractKeyTokens(rj) { const txt = JSON.stringify(rj || {}); // 去掉 stack 中的 path/random 字符,取 4 字以上小写单词 return [...new Set(txt.toLowerCase().match(/[a-z]{4,}/g) || [])] .filter(t => !['function','expected','received','stack','message'].includes(t)); } module.exports = { classify };
这是「启发式建议」工具,Agent 必须自己审一遍再写 triage.json。category_classifier 给的 confidence 仅是参考,Agent 输出的 confidence 是基于自己 reasoning 的最终判断。
⑧ 自检 Checklist
| # | 检查项 | 命令 | 判定 |
|---|---|---|---|
| 1 | triage.json typebox 合规 | schema check (M-9) | true |
| 2 | root_cause ≥ 20 字 | length | true |
| 3 | root_cause 含行为动词(返/抛/收/写/读/超时之一) | regex | 命中 |
| 4 | 所有 evidence grep 命中 | verifyAll(pid, evidence) | ok=true |
| 5 | category × proposed_sdd_patch null 一致 | 逻辑 | contract_mismatch ⇔ patch≠null |
| 6 | category × confidence 合理 | spec_ambiguous <0.6, impl_bug typically >0.7 | 无明显错配 |
| 7 | affected_units ⊆ units.json | set 包含 | true |
| 8 | category 非 unknown | 必为 4 选 1 | true |
| 9 | todolist 全 done | read | true |
| 10 | 没写 triage.json 之外的文件 | scanSandbox | violations=[] |
任一不过 → 不写 triage.json,retry 1 次。仍不过 → 写一个 confidence=0.3 + category='spec_ambiguous' 的兜底版升 L3。
⑨ 失败模式
| 场景 | 处置 |
|---|---|
| evidence 引文找不到原文 | extract_evidence 拒;Triager 重生这条 evidence |
| category 选错(后续 L2 再次失败) | 第 2 次 triage 自动升 spec_ambiguous(避免死循环) |
| root_cause 是「测试失败」类废话 | self-check 3 拒 |
| affected_units 空 + category=impl_bug | 不合法,拒;Triager 必须列至少 1 个 |
| proposed_sdd_patch 写错 target_section | Architect 应用时报错,Triager 重生 |
| 读 process.log 时间窗口太大(> 10MB) | extract_evidence 工具截取 failure 时间 ± 30s |
| 同 run 已有 triage.json | 视为 resume 模式(取代旧版,归档到 history/) |
| Triager 自己崩 | LoopController 写一个最小 spec_ambiguous triage 兜底 |
⑩ 握手摘要
| 对方 | 给 | 收 |
|---|---|---|
| LoopController | TriagerOutput | TriagerInput |
| Integrator | (无) | result.json + process.log + http-trace |
| Architect | triage.json(间接 via LoopController) | sdd/ (只读) |
| UnitRegistry | affected_units(间接) | — |
| Human (L3) | spec_ambiguous 时 l3_question 字段 | — |
| BugTracker | (LoopController 据 category 改 bug.category) | 历史相似 bug |
| memory/devloop/bug-triager/ | 新归因经验 | 启动注入 |
⑪ 实战验证
# 前置: Integrator 跑过一次 fail,有 integration/runs/{runId}/result.json # 1. 注册模板 # system/templates/devloop-triager.md ← 本页 §2.2 # 2. 准备输入 echo '{"pid":"p-test001","run_id":"run-int-20260513-1230","mode":"triage","call_id":"call-b-1","company_id":"c-test"}' > /tmp/tri.json # 3. 跑 BugTriager curl -X POST http://localhost:3010/api/devloop/_test/run-agent/bug-triager -d @/tmp/tri.json # 4. 期望 20-60 秒内 # data/companies/c-test/projects/p-test001/integration/runs/{runId}/triage.json # 5. 验证 schema + evidence node -e " const fs = require('fs'); const path = 'data/companies/c-test/projects/p-test001/integration/runs/run-int-20260513-1230/triage.json'; const t = JSON.parse(fs.readFileSync(path, 'utf8')); console.log('category:', t.category); console.log('confidence:', t.confidence); console.log('affected:', t.affected_units); const ev = require('./server/services/agent-tools/extract-evidence'); console.log('evidence verify:', ev.verifyAll('p-test001', t.evidence)); " # 6. 测三种典型场景 # 场景 A · contract_mismatch:故意让 SDD 与 contract.json 不一致 → 期望 patch 非空 # 场景 B · impl_bug:故意让 code 实现违反 SDD → 期望 patch=null # 场景 C · infra_flake:杀掉一个 be 进程,Integrator 报 ECONNREFUSED → 期望 affected=[] # 验证决策合理性 (人工抽查 3-5 个 triage.json) # 关键看: 同样的 result.json 反复跑 BugTriager,category 应稳定 (Opus 4.7 的判断不能 flaky)
BugTriager §⑪ 通过 = L2 完整闭环验证完成。四个 Agent 全部 §⑪ 通过 = 整个 DevLoop 系统可执行性证毕。下一步是在真实代码里跑 P1 + P2 + P3 + P4 的实施。