ci / vet-and-test (pull_request) Successful in 43s
Why: The plan's E4 remainder under the MiniMax-only constraint: the harness must detect the model going in circles and inject a change-of-strategy instruction (R4 - the model cannot be trusted to notice its own repeat-fail loop), and number sourcing needs addressable evidence: tool results carry [src_N|tool] tags so "found in materials" becomes "found in source src_N" for both the model and the fact-check notice. Why: Failure counting lives in one place (onEvent end, keyed by the slot status) because tool execution is parallel; the ledger and tag append are serialized by the same mutex, and escalation reminders fire once per condition streak - following the dual-speed nudge finding that MiniMax ignores vague reminders but follows concrete instructions. Evidence: go test ./... green; new anchors TestEscalationOnConsecutive ToolFailures, TestEscalationOnZeroHitSearches, TestEvidenceLedger TagsSource, TestEvidenceLedgerSkipsNonEvidenceTools. Live before/ after comparison runs on the Dev side per ruling 6. Impact: knowledge/web/tavily searches, fs_read_file and the workspace read_file/grep results now end with a source tag and enter the run ledger (2k-char head); two consecutive tool failures or two empty search results inject a concrete change-of-strategy reminder; base.deliverables v7 and agent-sub-research v3 explain the tags; fact-check notice points at src results. Non-evidence tools and failures are unchanged. Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E4, R4); Dev task feature/agent-e4-escalation-ledger.
338 lines
12 KiB
Go
338 lines
12 KiB
Go
package runtime
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
|
||
core "agent-system-core"
|
||
|
||
"agent-system-backend/internal/quality"
|
||
)
|
||
|
||
// factCheckMaxRounds 是 E3 的事实核查修正轮上限:两轮(第二轮为「最后一次
|
||
// 拦截」+标注出路),之后放行并记检查行。
|
||
const factCheckMaxRounds = 2
|
||
|
||
// P5:事实一致性核查。从交付物正文抽取可机械定位的「硬事实 token」——
|
||
// 百分比、日期、大额数字——并回溯本轮材料(附件解析文本 + 工具输出证据)。
|
||
// 找不到出处的 token 以「未核实」反馈给模型修一轮;模型可以选择删除、
|
||
// 改成材料里的数值、或明确标注为推测/目标值。人名/称谓不做机械比对
|
||
// (材料里多为"说话人N",机械比对必然误报)。
|
||
var (
|
||
factPctRe = regexp.MustCompile(`\d+(?:\.\d+)?%`)
|
||
factDateRe = regexp.MustCompile(`\d{1,2}月\d{1,2}日|\d{4}年|\d{4}-\d{1,2}-\d{1,2}`)
|
||
factBigNumRe = regexp.MustCompile(`\d[\d,]{4,}`)
|
||
)
|
||
|
||
// ExtractFacts 抽取交付物文本里的硬事实 token(去重保序)。
|
||
func ExtractFacts(text string) []string {
|
||
var out []string
|
||
seen := map[string]bool{}
|
||
add := func(tok string) {
|
||
tok = strings.TrimSpace(tok)
|
||
if tok == "" || seen[tok] {
|
||
return
|
||
}
|
||
seen[tok] = true
|
||
out = append(out, tok)
|
||
}
|
||
for _, m := range factPctRe.FindAllString(text, -1) {
|
||
add(m)
|
||
}
|
||
for _, m := range factDateRe.FindAllString(text, -1) {
|
||
add(m)
|
||
}
|
||
for _, m := range factBigNumRe.FindAllString(text, -1) {
|
||
add(m)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// UnverifiedFacts 返回材料里找不到出处的 token。放行三类:豁免的通用口径词、
|
||
// 材料中能回溯的、以及 E3 标注感知——文中以「目标/预计/假设」等标注词显式
|
||
// 标记为规划量的数字不再是凭空事实,机械溯源放行(MarkedAsEstimate)。
|
||
func UnverifiedFacts(artifactText, materialText string, exempt map[string]bool) []string {
|
||
if strings.TrimSpace(artifactText) == "" || strings.TrimSpace(materialText) == "" {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for _, tok := range ExtractFacts(artifactText) {
|
||
if exempt[tok] {
|
||
continue
|
||
}
|
||
if !strings.Contains(materialText, tok) && !strings.Contains(materialText, stripPunct(tok)) && !MarkedAsEstimate(artifactText, tok) {
|
||
out = append(out, tok)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// factMarkingRe:数字邻域出现这些词即视为「已标注的规划量」。选取保守集合,
|
||
// 避免把「会议目标」之类的普通动词误当标注(需要与数字同现于邻域内)。
|
||
var factMarkingRe = regexp.MustCompile(`目标|假设|预计|预估|推测|估算|规划|拟定|建议值|上限|下限|阈值|基准值|参考值|暂定`)
|
||
|
||
// factClauseCut 是分句分隔符:标注判定以「句」为单位而不是固定窗口——
|
||
// 固定窗口会把相邻句的标注词错标给前一个数字(实测:「覆盖 3,500 用户,
|
||
// 目标 85%」里 3,500 被误放行)。
|
||
const factClauseCut = "。;!?\n,、;"
|
||
|
||
// MarkedAsEstimate 报告 token 在 text 中的任一出现是否与标注词同句
|
||
// (同句=两侧最近分隔符之间,括号不算分隔,覆盖「目标完成率 85%」「85%
|
||
// (预计)」)。供终轮核查与评测端共用同一口径。
|
||
func MarkedAsEstimate(text, tok string) bool {
|
||
from := 0
|
||
for {
|
||
i := strings.Index(text[from:], tok)
|
||
if i < 0 {
|
||
return false
|
||
}
|
||
i += from
|
||
lo := strings.LastIndexAny(text[max(0, i-64):i], factClauseCut) + max(0, i-64) + 1
|
||
if end := strings.IndexAny(text[i:], factClauseCut); end >= 0 {
|
||
clause := text[lo : i+end]
|
||
if factMarkingRe.MatchString(clause) {
|
||
return true
|
||
}
|
||
} else if factMarkingRe.MatchString(text[lo:]) {
|
||
return true
|
||
}
|
||
from = i + len(tok)
|
||
}
|
||
}
|
||
|
||
func stripPunct(s string) string {
|
||
return strings.NewReplacer(",", "", ",", "", " ", "").Replace(s)
|
||
}
|
||
|
||
// factCheckNotice 把未核实 token 组成终轮反馈(给模型的一轮修正机会)。
|
||
func factCheckNotice(tokens []string) string {
|
||
if len(tokens) == 0 {
|
||
return ""
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString("交付物里以下数字/日期在本次材料中找不到出处:")
|
||
for i, tok := range tokens {
|
||
if i >= 12 {
|
||
b.WriteString(" 等")
|
||
break
|
||
}
|
||
if i > 0 {
|
||
b.WriteString("、")
|
||
}
|
||
b.WriteString(tok)
|
||
}
|
||
b.WriteString("。逐条二选一:改成材料或来源结果(检索/读取输出带【src_N|…】标记)里能找到的数值,或删除/明确标注为推测或目标值(如“目标 85%”)。不要保留无法回溯的裸数字。")
|
||
return b.String()
|
||
}
|
||
|
||
// materialCorpus 汇总本轮可见材料:附件解析文本 + 工具证据文本。
|
||
// 证据用完整 verifyText(尾部 600 字)而非 160 字摘要——足够定位数字。
|
||
func (st *directRun) materialCorpus() string {
|
||
var b strings.Builder
|
||
for _, doc := range st.rc.Attachments() {
|
||
b.WriteString(doc.PlainText())
|
||
b.WriteString("\n")
|
||
}
|
||
for _, ev := range st.evidence {
|
||
b.WriteString(ev.VerifyText)
|
||
b.WriteString("\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// deliverableText 拼接本轮交付工件的文本内容(UTF-8 文本直接读;docx
|
||
// 由 quality 的解析口径承担——这里只收集,不重复解析)。
|
||
func (st *directRun) deliverableText(refs []*artifactTextPair) string {
|
||
var b strings.Builder
|
||
for _, pair := range refs {
|
||
b.WriteString(pair.text)
|
||
b.WriteString("\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
type artifactTextPair struct {
|
||
id string
|
||
name string
|
||
text string
|
||
}
|
||
|
||
// collectDeliverableText 经工件端口读取文本类交付物内容
|
||
// (docx 用 quality 同源的抽取口径;二进制工件跳过)。
|
||
func (st *directRun) collectDeliverableText(refs []*core.ArtifactRef) []artifactTextPair {
|
||
lookup := artifactLookupOf(st.rc)
|
||
var out []artifactTextPair
|
||
for _, ref := range refs {
|
||
if ref == nil || ref.ArtifactID == "" {
|
||
continue
|
||
}
|
||
name, content, ok := lookup(string(ref.ArtifactID))
|
||
if !ok || len(content) == 0 {
|
||
continue
|
||
}
|
||
text := deliverablePlainText(name, content)
|
||
if text != "" {
|
||
out = append(out, artifactTextPair{id: string(ref.ArtifactID), name: name, text: text})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// deliverablePlainText 用 quality 的同源口径抽取工件文本。
|
||
func deliverablePlainText(name string, content []byte) string {
|
||
return quality.DeliverableText(name, content)
|
||
}
|
||
|
||
// concatArtifactText 拼接工件文本对。
|
||
func concatArtifactText(pairs []artifactTextPair) string {
|
||
var b strings.Builder
|
||
for _, p := range pairs {
|
||
b.WriteString(p.text)
|
||
b.WriteString("\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// factExemptions 豁免通用口径词(非事实性数字)。
|
||
func factExemptions() map[string]bool {
|
||
return map[string]bool{
|
||
"100%": true, "0%": true, // 常见修辞(100% 确定)与零值
|
||
}
|
||
}
|
||
|
||
// factCheckGate 决定本轮未核实 token 的处置(E3):
|
||
// - 第一轮:给修正通知并续跑;
|
||
// - 第二轮(仅当 token 集合相对上一轮有变化——模型确实改过但仍有残留):
|
||
// 「最后一次拦截」+ 标注出路;
|
||
// - token 集合与上一轮完全相同(模型没有理会)或轮数用尽:放行,
|
||
// 由调用方记入检查行如实呈现。续写预算是各终轮门禁共享的,
|
||
// 无变化的重复拦截只会挤占预算(实测会挤掉后续门禁的修正机会)。
|
||
func (st *directRun) factCheckGate(tokens []string) (notice string, cont bool) {
|
||
same := st.factCheckPrev != nil && len(tokens) == len(st.factCheckPrev)
|
||
if same {
|
||
for _, tok := range tokens {
|
||
if !st.factCheckPrev[tok] {
|
||
same = false
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if st.factCheckRounds >= factCheckMaxRounds || same {
|
||
return "", false
|
||
}
|
||
st.factCheckRounds++
|
||
st.factCheckPrev = map[string]bool{}
|
||
for _, tok := range tokens {
|
||
st.factCheckPrev[tok] = true
|
||
}
|
||
notice = factCheckNotice(tokens)
|
||
if st.factCheckRounds > 1 {
|
||
notice = "最后一次拦截:" + notice + " 若该数字确属规划量而非事实,必须在文中用「目标 / 预计 / 假设」等词显式标注(如「目标 85%」);既无出处又不标注的数字不得保留。"
|
||
}
|
||
return notice, true
|
||
}
|
||
|
||
// ---- E4:证据账本与 R4 升级提醒 ----
|
||
|
||
// evidenceEntry 是账本的一条来源记录(正文截头保存;全文在转录与工具证据里)。
|
||
type evidenceEntry struct {
|
||
ID string
|
||
Tool string
|
||
Text string
|
||
}
|
||
|
||
// evidenceTools 登记进 source_id 账本的证据类工具(读检索面)。
|
||
var evidenceTools = map[string]bool{
|
||
"knowledge_search": true,
|
||
"web_search": true,
|
||
"tavily_search": true,
|
||
"fs_read_file": true,
|
||
"read_file": true, // 内核工作区读(直连路径实际使用的名字)
|
||
"grep": true,
|
||
}
|
||
|
||
// escalationFailureThreshold 是连续工具失败的换路提醒阈值(R4)。
|
||
const escalationFailureThreshold = 2
|
||
|
||
// tagSource 把一次成功的证据工具输出登记进账本,返回给模型看的来源标记
|
||
// (如 src_3|knowledge_search)。并行执行下由 toolMu 串行化计数与追加。
|
||
func (st *directRun) tagSource(tool, text string) (string, bool) {
|
||
st.toolMu.Lock()
|
||
defer st.toolMu.Unlock()
|
||
st.ledgerCount++
|
||
id := fmt.Sprintf("src_%d", st.ledgerCount)
|
||
head := []rune(text)
|
||
if len(head) > 2000 { // 账本只存索引性头部,全文在转录/证据里
|
||
head = head[:2000]
|
||
}
|
||
st.ledger = append(st.ledger, evidenceEntry{ID: id, Tool: tool, Text: string(head)})
|
||
return id + "|" + tool, true
|
||
}
|
||
|
||
// noteEvidenceCall 维护 R4 的检索空命中计数(成功但无实质内容的证据类
|
||
// 工具调用)。工具成败计数不在这里——并行下以 onEvent 终点的槽位状态为
|
||
// 单一事实源(noteFailureOutcome),避免两处重复计数。
|
||
func (st *directRun) noteEvidenceCall(name, status, output string) {
|
||
if status != "ok" {
|
||
return
|
||
}
|
||
st.toolMu.Lock()
|
||
defer st.toolMu.Unlock()
|
||
trimmed := strings.TrimSpace(output)
|
||
if evidenceTools[name] && (trimmed == "" || containsAny(trimmed, zeroHitMarkers)) {
|
||
st.zeroHitSearches++
|
||
} else if evidenceTools[name] {
|
||
st.zeroHitSearches = 0
|
||
}
|
||
}
|
||
|
||
// zeroHitMarkers 是检索结果「无命中」的常见表述(按工具输出原文判别)。
|
||
var zeroHitMarkers = []string{"未找到", "没有找到", "无结果", "无相关", "暂无匹配", "No results"}
|
||
|
||
func containsAny(s string, subs []string) bool {
|
||
for _, sub := range subs {
|
||
if strings.Contains(s, sub) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// escalationReminders 返回待注入的换路提醒并清标记(每类条件一次,
|
||
// 连续失败再次触发时会重新提醒——失败计数在成功后重置,streak 标记随重置)。
|
||
// 文案直接给动作:MiniMax 对泛泛提醒不敏感(实测),对具体指令照办。
|
||
func (st *directRun) escalationReminders() []string {
|
||
var out []string
|
||
st.toolMu.Lock()
|
||
fails, failNoted := st.consecToolFailures, st.failStreakNoted
|
||
zero, zeroNoted := st.zeroHitSearches, st.zeroHitNoted
|
||
if fails >= escalationFailureThreshold && !failNoted {
|
||
st.failStreakNoted = true
|
||
}
|
||
if zero >= escalationFailureThreshold && !zeroNoted {
|
||
st.zeroHitNoted = true
|
||
}
|
||
st.toolMu.Unlock()
|
||
if fails >= escalationFailureThreshold && !failNoted {
|
||
out = append(out, fmt.Sprintf("工具已连续失败 %d 次:停止原样重试。逐条重读最近一次错误信息,改参数、换工具或换路径再试;确认无法绕过时,如实在答复里说明卡点与已尝试的手段,不要编造结果。", fails))
|
||
}
|
||
if zero >= escalationFailureThreshold && !zeroNoted {
|
||
out = append(out, "检索已连续多次无结果:换关键词/换检索维度再试一次;仍无结果就如实写「材料中无此信息」,不要凭印象补数字。")
|
||
}
|
||
return out
|
||
}
|
||
|
||
// noteFailureOutcome 供 onEvent 在工具结束时同步成败计数(并行下事件乱序,
|
||
// 以 runToolCtx 已写入槽位的 status 为准)。
|
||
func (st *directRun) noteFailureOutcome(status string) {
|
||
st.toolMu.Lock()
|
||
defer st.toolMu.Unlock()
|
||
if status == "error" {
|
||
st.consecToolFailures++
|
||
} else {
|
||
st.consecToolFailures = 0
|
||
st.failStreakNoted = false // 新失败链可再触发提醒
|
||
}
|
||
}
|