Keep only hard facts in the blocking gate and add fact-consistency checks
ci / vet-and-test (pull_request) Successful in 40s

Why: User rulings 4/5 plus the P2-P4 live evidence. Keyword scoring dimensions
(readability, actionability) kept failing valid documents - normal Chinese
section headings flagged as column titles, loops of 4 corrections - because
they grade writing preferences, not verifiable facts. Unverified numbers
(~10-13 per run) remained the largest quality leak through every phase.
And the verifier runs on the same model as the worker (lightweight falls back
to chat), so "independent" verification was self-review. Separately, svc
seeding after boot never registered the tavily tools until a restart because
keys were snapshotted at startup.

Changes: quality.HardFacts (lint actually ran + zero errors) replaces the
full ScoreBytes projection in CheckArtifacts - keyword dimensions remain in
ScoreBytes for eval visibility only. New fact-consistency check
(runtime.factCheck, default on): extract percentages/dates/large numbers from
deliverable text, trace each against the material corpus (attachment text +
tool evidence); misses get exactly one correction round (fix, drop, or mark
as target/estimate), never a loop. runVerifier skips same-source model
verification unless runtime.verifierSameModel is set (hard facts + fact check
still run). Search tools now register lazily, reading tavily keys per call
(settings/seeding take effect immediately).

Evidence: SELF - TestHardFactsIgnoresKeywordDimensions,
TestHardFactsKeepsLintUnavailableBlocking, TestUnverifiedFactsAndNotice,
TestFactCheckContinuationOnce (fact note fires, corrected run passes);
go vet + go test ./... pass; live before/after on the golden task set
recorded on the phase05 task card per the policy clause.

Impact: Gate stops rejecting valid documents on stylistic grounds; invented
figures get one forced correction round; same-model verification no longer
pretends to be independent; tavily config is hot. No contract or storage
changes.
This commit is contained in:
2026-10-10 00:04:57 +00:00
parent 1a3fbfb3d8
commit ef060d4ee5
22 changed files with 416 additions and 48 deletions
+15 -11
View File
@@ -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 {
+5 -5
View File
@@ -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 {
+1 -1
View File
@@ -2,8 +2,8 @@ package plugins
import (
"os"
"strings"
"path/filepath"
"strings"
"testing"
"time"
+54
View File
@@ -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)
}
}
+40
View File
@@ -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
}
+1 -1
View File
@@ -52,7 +52,7 @@ func TestLintUnavailableFailsVisibleWhenSkillsRootSet(t *testing.T) {
}
// docx 标题样式要被标成 # 前缀;表格单元格/正文不参与「栏目式标题」判定
//(实测误报:单元格「项目概述撰写」被当成标题,检查连续判负)。
// (实测误报:单元格「项目概述撰写」被当成标题,检查连续判负)。
func TestDocxHeadingMarkedAndTableCellNotFlagged(t *testing.T) {
docx := buildDocx(t, []paraSpec{
{style: "Heading1", text: "概述"},
+1 -1
View File
@@ -3,7 +3,7 @@ package route
import (
"testing"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentgate"
core "agent-system-core"
)
+162
View File
@@ -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% 确定)与零值
}
}
+65
View File
@@ -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
}
+6 -3
View File
@@ -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, ""
}
+17 -2
View File
@@ -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())
+2 -2
View File
@@ -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)
}
})
+10 -1
View File
@@ -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("验证器调用失败")
}
+2 -2
View File
@@ -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()
+2
View File
@@ -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 一致)"},
-1
View File
@@ -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) {
@@ -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)
+1 -1
View File
@@ -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{
+3 -3
View File
@@ -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) {
+16
View File
@@ -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{
+1 -1
View File
@@ -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 场景)经两个入口
+12 -12
View File
@@ -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(<StepID>_critic)
VerdictField string // 黑板判定字段名
StepID string // 被评审的原步 ID
StepTitle string
EvalID string // 评审步 ID(<StepID>_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")),