diff --git a/internal/app/app.go b/internal/app/app.go index 3167aa1..ca2bf58 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -176,18 +176,22 @@ func Run(opt Options) error { rag.SecretLookup = func(service, fallback string) string { return runtime.LookupSecret(store.OwnerDefault, service, fallback) } - // 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。 - eff := setSvc.Effective(store.OwnerDefault, "") - tavilyRaw := eff.GetString("tools.tavily.apiKeys", "") - if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok { - tavilyRaw = active - } - if keys := splitKeyPool(tavilyRaw); len(keys) > 0 { - provider := agenttools.NewTavilyProvider(eff.GetString("tools.tavily.endpoint", ""), keys) - if err := tools.RegisterSearchTools(reg, provider); err != nil { - return fmt.Errorf("search tools: %w", err) + // 网络搜索(Tavily):始终注册,密钥按调用时读取(设置页/svc 播种改了 + // 即刻生效——此前启动时快照密钥,播种后必须重启工具才注册)。 + // 未配置时调用返回明确的「未配置」提示,不静默降级为空结果。 + if err := tools.RegisterLazySearchTools(reg, func() agenttools.SearchProvider { + effNow := setSvc.Effective(store.OwnerDefault, "") + tavilyNow := effNow.GetString("tools.tavily.apiKeys", "") + if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok { + tavilyNow = active } - log.Printf("tools: tavily_search/web_search registered (%d key(s))", len(keys)) + keys := splitKeyPool(tavilyNow) + if len(keys) == 0 { + return nil + } + return agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys) + }); err != nil { + return fmt.Errorf("search tools: %w", err) } promptLib, err := prompts.DefaultLibrary() if err != nil { diff --git a/internal/cardsvc/cardsvc.go b/internal/cardsvc/cardsvc.go index f000934..66f867b 100644 --- a/internal/cardsvc/cardsvc.go +++ b/internal/cardsvc/cardsvc.go @@ -27,14 +27,14 @@ var ( const cardAppendWindow = 250 * time.Millisecond type Service struct { - st *store.Store - emit Emitter + st *store.Store + emit Emitter // MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量 // 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。 MirrorConvCards bool - now func() time.Time - emitMu sync.Mutex - lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡) + now func() time.Time + emitMu sync.Mutex + lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡) } func New(st *store.Store, emit Emitter) *Service { diff --git a/internal/plugins/host_v2_test.go b/internal/plugins/host_v2_test.go index c22778f..be0c5fd 100644 --- a/internal/plugins/host_v2_test.go +++ b/internal/plugins/host_v2_test.go @@ -2,8 +2,8 @@ package plugins import ( "os" - "strings" "path/filepath" + "strings" "testing" "time" diff --git a/internal/quality/hardfacts_test.go b/internal/quality/hardfacts_test.go new file mode 100644 index 0000000..f569880 --- /dev/null +++ b/internal/quality/hardfacts_test.go @@ -0,0 +1,54 @@ +package quality + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// P5/裁定 4:拦截路径只认硬事实(lint 实际运行且零 error); +// 关键词维度(栏目式标题/可执行性等)不得再把合格交付物拦下来。 +func TestHardFactsIgnoresKeywordDimensions(t *testing.T) { + dir := t.TempDir() + // 评分整体会因「结论先行 20/可读性 25」判负的文本——硬事实口径必须放行 + //(无 lint 或 lint 通过时)。 + path := filepath.Join(dir, "报告.md") + if err := os.WriteFile(path, []byte("# 概述\n\n正文内容。\n"), 0o644); err != nil { + t.Fatal(err) + } + full := ScoreFile(path) + if full.Passed { + t.Fatalf("keyword dimensions should still fail the full score (fixture sanity): %+v", full) + } + hard := HardFacts("报告.md", []byte("# 概述\n\n正文内容。\n")) + if !hard.Passed { + t.Fatalf("hard facts must ignore keyword dimensions: %+v", hard.Violations) + } + if len(hard.Lint) > 0 { + for _, issue := range hard.Lint { + if issue.Severity == "error" { + t.Fatalf("lint errors must still block: %+v", issue) + } + } + } +} + +// lint 不可运行(技能根已配置)仍是硬事实失败——fail-visible 保持。 +func TestHardFactsKeepsLintUnavailableBlocking(t *testing.T) { + SetLintEnvRoot(t.TempDir()) + t.Cleanup(func() { SetLintEnvRoot("") }) + hard := HardFacts("报告.md", []byte("# 概述\n正文。\n")) + if hard.Passed { + t.Fatalf("lint-not-run must stay blocking: %+v", hard) + } + found := false + for _, v := range hard.Violations { + if strings.Contains(v, "自检未运行") { + found = true + } + } + if !found { + t.Fatalf("expected 自检未运行, got %v", hard.Violations) + } +} diff --git a/internal/quality/score.go b/internal/quality/score.go index 27ed7ca..fa1bd76 100644 --- a/internal/quality/score.go +++ b/internal/quality/score.go @@ -451,3 +451,43 @@ func firstLine(s string) string { } return s } + +// HardFacts 只评可机械判定的硬事实:lint 实际运行且零 error。 +// 关键词维度(ScoreText 六维)从拦截路径移出(裁定 4/P5):它们描述的是 +// 写作质量偏好,实测会把正常中文章节标题判成栏目名、把没有"未指明"字样的 +// 行动表判成不可执行,造成终轮死循环;评分整体仍由 ScoreBytes 供评测侧使用。 +func HardFacts(name string, content []byte) Report { + report := ScoreBytes(name, content) + report.Passed = true + report.Violations = nil + for _, issue := range report.Lint { + if issue.Severity == "error" { + report.Passed = false + report.Violations = append(report.Violations, "版式 "+issue.Rule+":"+issue.Message) + } + } + if !report.LintRan && lintEnvRoot != "" { + report.Passed = false + report.Violations = append(report.Violations, "自检未运行(housestyle.lint 不可用)") + } + return report +} + +// DeliverableText 供事实核查抽取工件文本(docx 与 ScoreBytes 同源口径; +// 二进制返回空)。导出给 runtime 包使用。 +func DeliverableText(name string, content []byte) string { + dir, err := os.MkdirTemp("", "factcheck") + if err != nil { + return "" + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, name) + if err := os.WriteFile(path, content, 0o644); err != nil { + return "" + } + text, err := readDeliverable(path) + if err != nil { + return "" + } + return text +} diff --git a/internal/quality/score_lint_test.go b/internal/quality/score_lint_test.go index 8b3bee2..e81d261 100644 --- a/internal/quality/score_lint_test.go +++ b/internal/quality/score_lint_test.go @@ -52,7 +52,7 @@ func TestLintUnavailableFailsVisibleWhenSkillsRootSet(t *testing.T) { } // docx 标题样式要被标成 # 前缀;表格单元格/正文不参与「栏目式标题」判定 -//(实测误报:单元格「项目概述撰写」被当成标题,检查连续判负)。 +// (实测误报:单元格「项目概述撰写」被当成标题,检查连续判负)。 func TestDocxHeadingMarkedAndTableCellNotFlagged(t *testing.T) { docx := buildDocx(t, []paraSpec{ {style: "Heading1", text: "概述"}, diff --git a/internal/route/route_test.go b/internal/route/route_test.go index 8c66a9c..86bbfab 100644 --- a/internal/route/route_test.go +++ b/internal/route/route_test.go @@ -3,7 +3,7 @@ package route import ( "testing" - "agent-system-backend/internal/intentgate" + "agent-system-backend/internal/intentgate" core "agent-system-core" ) diff --git a/internal/runtime/factcheck.go b/internal/runtime/factcheck.go new file mode 100644 index 0000000..08e0e1b --- /dev/null +++ b/internal/runtime/factcheck.go @@ -0,0 +1,162 @@ +package runtime + +import ( + "regexp" + "strings" + + core "agent-system-core" + + "agent-system-backend/internal/quality" +) + +// 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(白名单:豁免的通用口径词)。 +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)) { + out = append(out, tok) + } + } + return out +} + +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("。逐条二选一:改成材料里能找到的数值,或删除/明确标注为推测或目标值(如“目标 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% 确定)与零值 + } +} diff --git a/internal/runtime/factcheck_test.go b/internal/runtime/factcheck_test.go new file mode 100644 index 0000000..fc10c5c --- /dev/null +++ b/internal/runtime/factcheck_test.go @@ -0,0 +1,65 @@ +package runtime + +import ( + "context" + "strings" + "testing" + + core "agent-system-core" +) + +// 事实核查:交付物里材料找不到出处的百分比被抽出并组成反馈;找到的不报。 +func TestUnverifiedFactsAndNotice(t *testing.T) { + material := "目标是准确率 85%以上。截止 10月12日。团队共 12 人。" + artifact := "准确率达到 95%,截止 10月20日,覆盖 3,500 用户,目标 85%。" + got := UnverifiedFacts(artifact, material, factExemptions()) + joined := strings.Join(got, ",") + for _, want := range []string{"95%", "10月20日", "3,500"} { + if !strings.Contains(joined, want) { + t.Fatalf("missing %q in %v", want, got) + } + } + if strings.Contains(joined, "85%") { + t.Fatalf("sourced figure must not be reported: %v", got) + } + notice := factCheckNotice(got) + if !strings.Contains(notice, "95%") || !strings.Contains(notice, "改成材料里") { + t.Fatalf("notice malformed: %q", notice) + } + if UnverifiedFacts("", material, nil) != nil || UnverifiedFacts(artifact, "", nil) != nil { + t.Fatal("empty sides short-circuit") + } +} + +// 端到端:终轮对带未核实数字的交付物给一轮修正;修正后(重新登记干净版本)放行。 +func TestFactCheckContinuationOnce(t *testing.T) { + rc := &fakeRunCtx{ + ctx: context.Background(), + prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, + verify: true, + execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"}, + execArtifactContent: []byte("# 报告\n\n准确率 95%。"), + } + // 第一轮:登记带编造数字的工件 -> 事实核查续跑一次;第二轮:干净版本通过。 + turns1 := padTurns( + textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)), + textDeltaTurn("写好了。"), + ) + turns2 := padTurns( + textDeltaTurn("材料里是 85%。"), + ) + r := &LLMRunner{Stream: scriptStream(padTurns(turns1...), nil), VerifyStream: passedStream()} + if err := r.Execute(rc); err != nil { + t.Fatalf("Execute: %v", err) + } + sawFact := false + for _, p := range rc.assistant { + if p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "事实核查") { + sawFact = true + } + } + if !sawFact { + t.Fatalf("fact check continuation note missing: %+v", rc.assistant) + } + _ = turns2 +} diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 08659bd..4593858 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -582,10 +582,13 @@ func artifactLookupOf(rc RunCtx) gates.ArtifactLookup { } } -// qualityScorer 把 quality.ScoreBytes 投影为库的评分端口。 -func qualityScorer() gates.QualityScorer { +// hardFactScorer 把 quality.ScoreBytes 投影为「硬事实」检查端口(裁定 4/P5): +// 只拦可机械判定的硬事实——lint error(含 lint 未运行);关键词评分维度 +// (结论先行/可读性/可执行性等)只作观测记录,不再进入拦截路径—— +// 实测这些维度把正常章节标题判成栏目名导致终轮死循环。 +func hardFactScorer() gates.QualityScorer { return func(name string, content []byte) (bool, string) { - report := quality.ScoreBytes(name, content) + report := quality.HardFacts(name, content) if report.Passed { return true, "" } diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index fe9204a..668855a 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -59,6 +59,7 @@ type directRun struct { checklistTouched bool toolTurnsSince int nudgedPlanless bool + factChecked bool progress *gates.ProgressReminder checks []core.CheckPart servedModel string @@ -523,10 +524,24 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim // 终轮检查只评每个文件的最新版本:修订产生新工件后,被替代的旧版本 // 已不是交付物,继续按旧版评分会让检查永远无法通过(实测死循环根因)。 latest := st.latestDeliverables() - // N1 WP5:工件存在/非空/质量检查走下沉的 CheckArtifacts(端口在 llm.go 投影)。 - if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), qualityScorer()); len(reasons) > 0 { + // N1 WP5:工件存在/非空/硬事实检查走下沉的 CheckArtifacts(端口在 llm.go 投影; + // P5 后 scorer 只拦 lint error 与 lint 未运行)。 + if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), hardFactScorer()); len(reasons) > 0 { return st.continueOrNotice(info, strings.Join(reasons, ";")) } + // P5:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料; + // 找不到出处的给模型一轮修正(改值/删除/标注推测),修正过仍失败的 + // 不再拦截(记入检查行如实呈现),避免机械比对的误报变成死循环。 + if st.eff.GetBool("runtime.factCheck", true) && len(latest) > 0 && info.Continuations < gates.MaxVerifyContinuations && !st.factChecked { + pairs := st.collectDeliverableText(latest) + if len(pairs) > 0 { + if tokens := UnverifiedFacts(concatArtifactText(pairs), st.materialCorpus(), factExemptions()); len(tokens) > 0 { + st.factChecked = true + st.addVerifyNote("事实核查:" + factCheckNotice(tokens)) + return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物。\n" + factCheckNotice(tokens))} + } + } + } verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, latest) if err != nil { st.addVerifyNote(err.Error()) diff --git a/internal/runtime/llm_verify_test.go b/internal/runtime/llm_verify_test.go index 59f7334..c6f474d 100644 --- a/internal/runtime/llm_verify_test.go +++ b/internal/runtime/llm_verify_test.go @@ -219,11 +219,11 @@ func TestVerifierGarbageFailsClosed(t *testing.T) { if p.Text == "答案是 42。" && !p.Process { kept = true } - if !p.Process && strings.Contains(p.Text, "验证器不可用") { + if !p.Process && strings.Contains(p.Text, "验证器未独立配置") { notice = true } } - if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") { + if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") && !hasProcessNote(rc.assistant, "未独立配置") { t.Fatalf("parts = %+v", texts) } }) diff --git a/internal/runtime/verify.go b/internal/runtime/verify.go index 195cb26..703acf2 100644 --- a/internal/runtime/verify.go +++ b/internal/runtime/verify.go @@ -12,7 +12,7 @@ import ( const verifyCapNotice = "本轮回复未通过终轮检查。" -const verifierUnavailableNotice = "本轮回复未通过独立验证(验证器不可用)" +const verifierUnavailableNotice = "本轮未做同源模型验证(验证器未独立配置);硬事实与事实核查已执行" const verifyFallbackPrompt = `你是独立验证器,不调用任何工具。根据用户要求、终轮回复、工具证据和工件清单判断这一轮是否已经完成。 同一对象的后一次检查覆盖前一次,以最后一次为准。内置质量检查已通过时,不要用更早的工具输出推翻它。 @@ -29,6 +29,15 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, route := ResolveLightweightModel(rc.Profile(), rc.Settings()) stream := l.VerifyStream if stream == nil { + // P5:异模型验证——轻量档回落到与主模型同名时,验证器看到的输入与 + // 生成者同源(自证倾向、同样的解析毛病)。此时跳过模型验证,让 + // 硬事实/事实核查承担终轮检查,比假独立更诚实。 + chat := ResolveChatModel(rc.Profile(), rc.Settings()) + sameModel := strings.EqualFold(strings.TrimSpace(route.Model), strings.TrimSpace(chat.Model)) && + strings.EqualFold(strings.TrimSpace(route.Base), strings.TrimSpace(chat.Base)) + if !rc.Settings().GetBool("runtime.verifierSameModel", false) && sameModel { + return gates.Verdict{}, fmt.Errorf("验证器未配置独立轻量模型(当前与主模型相同),跳过同源验证") + } if route.Model == "" || route.Base == "" { return gates.Verdict{}, fmt.Errorf("验证器调用失败") } diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index 5cae00b..0b84e36 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -31,9 +31,9 @@ import ( "agent-system-backend/internal/profiles" "agent-system-backend/internal/runmgr" "agent-system-backend/internal/runtime" + "agent-system-backend/internal/settings" skillseed "agent-system-backend/internal/skills" "agent-system-backend/internal/squadadapter" - "agent-system-backend/internal/settings" "agent-system-backend/internal/store" "agent-system-backend/internal/tools" core "agent-system-core" @@ -929,7 +929,7 @@ func TestSquadOrchestration(t *testing.T) { py, _ := osexec.LookPath("python3") return nativeadp.Config{DefaultModel: "stub", SkillsRoots: []string{skillsRoot}, Exec: &nativetools.ExecConfig{Bash: "/bin/bash", Python: py}, Stream: stubNativeStream( - "# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true + "# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true } defer func() { squadadapter.AgentNativeOptions = savedNative }() h := srv.Handler() diff --git a/internal/settings/settings.go b/internal/settings/settings.go index a28b995..050a083 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -96,6 +96,8 @@ func Schema() []core.SettingDesc { {Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"}, {Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"}, {Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"}, + {Key: "runtime.factCheck", Group: "runtime", Label: "终轮事实核查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "交付物里的百分比/日期/大额数字回溯本轮材料,找不到出处的给模型一轮修正(修正过仍失败不拦截,只如实记录)"}, + {Key: "runtime.verifierSameModel", Group: "runtime", Label: "允许同源验证器", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "轻量档未单独配置时,默认跳过与主模型同源的独立验证(硬事实与事实核查仍执行);显式开启才用主模型做验证"}, {Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"}, {Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"}, {Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"}, diff --git a/internal/squadadapter/adapter_test.go b/internal/squadadapter/adapter_test.go index e61c06d..852f2e2 100644 --- a/internal/squadadapter/adapter_test.go +++ b/internal/squadadapter/adapter_test.go @@ -167,7 +167,6 @@ func containsStr(list []string, want string) bool { return false } - // Q3 WP2:agent-system 退役后,未注入 AgentNativeOptions 时解析器为空—— // 步骤后端解析 fail-closed,不再静默走 SpawnSubagent 压缩路径。 func TestBuildBackendsWithoutNativeOptionsIsEmpty(t *testing.T) { diff --git a/internal/squadadapter/orchestrate_native_test.go b/internal/squadadapter/orchestrate_native_test.go index 60906c0..77ba5df 100644 --- a/internal/squadadapter/orchestrate_native_test.go +++ b/internal/squadadapter/orchestrate_native_test.go @@ -54,7 +54,6 @@ func TestOrchestrateRunsOnKernelStub(t *testing.T) { } } - func stubStream(text string) agentllm.StreamFunc { return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event { ch := make(chan agentllm.Event, 8) diff --git a/internal/tools/exec_backend_test.go b/internal/tools/exec_backend_test.go index fc25ef4..03d0c85 100644 --- a/internal/tools/exec_backend_test.go +++ b/internal/tools/exec_backend_test.go @@ -36,7 +36,7 @@ func TestSandboxArgvShape(t *testing.T) { argv := sandboxArgv(ExecSpec{ Bin: "/usr/bin/python3", Args: []string{"/data/s/x.py"}, Dir: "/data/s", SkillsDir: "/data/skills", - Env: []string{"PATH=/usr/bin:/bin", "HOME=/tmp/h"}, + Env: []string{"PATH=/usr/bin:/bin", "HOME=/tmp/h"}, }) joined := strings.Join(argv, "\x00") musts := []string{ diff --git a/internal/tools/http_test.go b/internal/tools/http_test.go index 295d883..632685f 100644 --- a/internal/tools/http_test.go +++ b/internal/tools/http_test.go @@ -20,9 +20,9 @@ func (dummyCtx) PeerConversations() ([]core.Conversation, error) { return nil, n func (dummyCtx) PeerConversationMessages(id string, limit int) ([]core.Message, error) { return nil, nil } -func (dummyCtx) OwnerID() string { return "o" } -func (dummyCtx) WorkspaceRoot() string { return "" } -func (dummyCtx) SettingBool(string, bool) bool { return false } +func (dummyCtx) OwnerID() string { return "o" } +func (dummyCtx) WorkspaceRoot() string { return "" } +func (dummyCtx) SettingBool(string, bool) bool { return false } // TestHTTPExecutorTemplateAndPath:模板变量注入、结果路径裁剪、错误状态。 func TestHTTPExecutorTemplateAndPath(t *testing.T) { diff --git a/internal/tools/search.go b/internal/tools/search.go index e4b3a97..1315acc 100644 --- a/internal/tools/search.go +++ b/internal/tools/search.go @@ -22,7 +22,23 @@ type TavilySettings struct { // RegisterSearchTools 注册 tavily_search(完整参数面)与 web_search(别名)。 // 工具面与 agent-native runtime/tools/search.go 逐字段对齐(名称/参数/输出/失败降级文案)。 func RegisterSearchTools(r *Registry, provider agenttools.SearchProvider) error { + return registerSearch(r, func(ToolExecCtx) agenttools.SearchProvider { return provider }) +} + +// RegisterLazySearchTools 注册按调用时取密钥的搜索工具:provider 在每次调用时 +// 重建(设置页/svc 播种改了 tavily 密钥即刻生效,不再需要重启进程—— +// 实测播种后 tavily 工具要重启才注册)。configure 返回 nil 时不静默降级, +// 返回明确的未配置提示。 +func RegisterLazySearchTools(r *Registry, configure func() agenttools.SearchProvider) error { + return registerSearch(r, func(ToolExecCtx) agenttools.SearchProvider { return configure() }) +} + +func registerSearch(r *Registry, providerFor func(ToolExecCtx) agenttools.SearchProvider) error { tavily := func(rc ToolExecCtx, args json.RawMessage) (string, error) { + provider := providerFor(rc) + if provider == nil { + return "", fmt.Errorf("搜索未配置:settings.tools.tavily.apiKeys(或对应 secret)为空;配置后立即生效,无需重启") + } return execSearch(provider, args) } if err := r.Register(core.ToolDesc{ diff --git a/internal/wfcompile/ab_demo_test.go b/internal/wfcompile/ab_demo_test.go index 1fbe7bd..081b81b 100644 --- a/internal/wfcompile/ab_demo_test.go +++ b/internal/wfcompile/ab_demo_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - core "agent-system-core" "agent-system-backend/eval" "agent-system-backend/internal/workflow" + core "agent-system-core" ) // ab_demo_test.go — Q3 WP5 验收:同批任务(含 demo.brief 场景)经两个入口 diff --git a/internal/wfcompile/compiler.go b/internal/wfcompile/compiler.go index bfc0613..3dfb8ea 100644 --- a/internal/wfcompile/compiler.go +++ b/internal/wfcompile/compiler.go @@ -35,20 +35,20 @@ import ( // 宿主 intent 步的意图 ID(目录由本包合成,处理函数在 host.go)。 const ( - IntentHitl = "wf.hitl" - IntentTool = "wf.tool" + IntentHitl = "wf.hitl" + IntentTool = "wf.tool" IntentArtifact = "wf.artifact" - IntentCard = "wf.card" - IntentScript = "wf.script" - IntentCritic = "wf.critic" + IntentCard = "wf.card" + IntentScript = "wf.script" + IntentCritic = "wf.critic" ) // CriticPair 记录一个「原步 ↔ 评审步」映射(收口后校验最终判定用)。 type CriticPair struct { - StepID string // 被评审的原步 ID - StepTitle string - EvalID string // 评审步 ID(_critic) - VerdictField string // 黑板判定字段名 + StepID string // 被评审的原步 ID + StepTitle string + EvalID string // 评审步 ID(_critic) + VerdictField string // 黑板判定字段名 } // Compiled 是一次编译产物。 @@ -86,7 +86,7 @@ func Compile(def core.WorkflowDef, vars workflow.Vars, opts CompileOptions, know var steps []squad.StepSpec var agents []squad.RoleSpec agentSeen := map[string]bool{} - prevDef := "" // 上一个 def 步 ID(artifact 空内容的 prev 回退) + prevDef := "" // 上一个 def 步 ID(artifact 空内容的 prev 回退) prevChain := "" // 图链上的前驱(含评审步) for i, raw := range def.Steps { @@ -376,8 +376,8 @@ func buildCatalog() *squad.IntentCatalog { Revision: 1, Markdown: "# 工作流宿主能力\n\n- wf.hitl 人工问答\n- wf.tool 宿主工具\n- wf.artifact 工件落库\n- wf.card 收件箱卡\n- wf.script 脚本宿主\n- wf.critic 步骤验收\n", Specs: map[string]squad.IntentSpecView{ - IntentHitl: spec(IntentHitl, "人工问答步", req("question"), opt("step_id"), opt("kind"), opt("choices"), opt("allow_free_input"), opt("free_input_hint")), - IntentTool: spec(IntentTool, "宿主工具步", req("tool"), opt("step_id"), opt("args")), + IntentHitl: spec(IntentHitl, "人工问答步", req("question"), opt("step_id"), opt("kind"), opt("choices"), opt("allow_free_input"), opt("free_input_hint")), + IntentTool: spec(IntentTool, "宿主工具步", req("tool"), opt("step_id"), opt("args")), IntentArtifact: spec(IntentArtifact, "工件步", req("name"), opt("step_id"), opt("content"), opt("content_ref")), IntentCard: spec(IntentCard, "收件箱卡步", req("template"), opt("step_id")), IntentScript: spec(IntentScript, "脚本步", req("path"), opt("step_id"), opt("model")),