Keep the final reply clean when checks continue or the verifier is skipped #126

Merged
weichen merged 1 commits from feature/final-reply-pollution-fix into main 2026-10-10 07:56:41 +00:00
4 changed files with 127 additions and 9 deletions
+97
View File
@@ -0,0 +1,97 @@
package runtime
import (
"context"
"strings"
"testing"
core "agent-system-core"
)
// 问题一回归(run_b520bf2e3037):事实核查修正轮触发时,现场那段正式总结必须
// 降级进过程时间线;修正轮的收尾才是最终答复——否则前端按「最后一个过程部件」
// 切分后,用户只看到修正流水账,看不到原总结。
func TestFactCheckDemotesPriorSummary(t *testing.T) {
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model", Lightweight: "lite-model"}},
verify: true,
execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"},
execArtifactContent: []byte("# 报告\n\n准确率 95%。"),
}
turns := padTurns(
textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)),
textDeltaTurn("已完成。## 产出物\n\n结构完整的用户总结。"),
textDeltaTurn("已修正并重新登记。"),
)
r := &LLMRunner{Stream: scriptStream(turns, nil), VerifyStream: passedStream()}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
var summary *core.Part
visible := []string{}
sawFactCheck := false
for i := range rc.assistant {
p := rc.assistant[i]
if p.Type == core.PartText && strings.Contains(p.Text, "结构完整的用户总结") {
summary = &rc.assistant[i]
}
if p.Type == core.PartText && !p.Process {
visible = append(visible, p.Text)
}
if p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "事实核查") {
sawFactCheck = true
}
}
if summary == nil {
t.Fatal("prior summary text missing")
}
if !summary.Process {
t.Fatalf("prior summary not demoted to process: %+v", *summary)
}
if !sawFactCheck {
t.Fatal("fact check note missing")
}
if len(visible) != 1 || visible[0] != "已修正并重新登记。" {
t.Fatalf("visible final texts = %v, want only the continuation closing", visible)
}
}
// 同源跳过提示不再作为用户可见正文追加(原先经 StopDecision.Notice 顶掉正式总结),
// 改记为通过态检查行留在过程时间线。
func TestVerifierSkipIsCheckNotFinalText(t *testing.T) {
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, // 轻量回落=主模型 → 同源跳过
verify: true,
}
turns := padTurns(textDeltaTurn("总结完成。"))
r := &LLMRunner{Stream: scriptStream(turns, nil)} // 无 VerifyStream
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
skipCheck, noticeText := false, false
visible := []string{}
for _, p := range rc.assistant {
if p.Check != nil && p.Check.Passed && strings.Contains(p.Check.Reason, "同源模型验证") {
skipCheck = true
}
if p.Type == core.PartText {
if strings.Contains(p.Text, verifierUnavailableNotice) {
noticeText = true
}
if !p.Process {
visible = append(visible, p.Text)
}
}
}
if !skipCheck {
t.Fatal("same-source skip not recorded as a passing check")
}
if noticeText {
t.Fatal("skip notice leaked into message text parts")
}
if len(visible) != 1 || visible[0] != "总结完成。" {
t.Fatalf("visible final texts = %v", visible)
}
}
+18 -4
View File
@@ -537,15 +537,26 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
if len(pairs) > 0 {
if tokens := UnverifiedFacts(concatArtifactText(pairs), st.materialCorpus(), factExemptions()); len(tokens) > 0 {
st.factChecked = true
// 与交付物门禁/续写上限分支对齐:检查未通过时,现场那段总结归入
// 过程时间线——修正轮之后的收尾才是最终答复(实测 run_b520bf2e3037
// 未降级导致好总结被前端切分折叠,用户只看到修正流水账)。
st.demoteLastText()
st.addVerifyNote("事实核查:" + factCheckNotice(tokens))
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物。\n" + 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())
return nruntime.StopDecision{Notice: verifierUnavailableNotice}
// 验证器没跑成不再是用户可见正文(实测 Notice 会顶掉正式总结)。
// 同源跳过是能力说明,记通过态检查行;真失败记未通过态检查行,
// 过程时间线里都可见,最终答复只留模型自己的收尾。
if strings.Contains(err.Error(), "跳过同源验证") {
st.addCheck(true, verifierUnavailableNotice)
} else {
st.addVerifyNote(err.Error())
}
return nruntime.StopDecision{}
}
if !verdict.Passed {
reason := strings.TrimSpace(verdict.Reason)
@@ -640,7 +651,10 @@ func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nru
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason(reason)}
}
st.addVerifyNote(reason)
return nruntime.StopDecision{Notice: verifyCapNotice}
// 达到续写上限同样收进检查行(未通过态):让用户看见检查确实没过,
// 而不是用一句系统提示替换掉模型的最终答复。
st.addCheck(false, verifyCapNotice)
return nruntime.StopDecision{}
}
func (st *directRun) runTool(name, args string) agenttools.Result {
+5 -3
View File
@@ -214,16 +214,18 @@ func TestVerifierGarbageFailsClosed(t *testing.T) {
t.Fatalf("main calls = %d, want 1", mainCalls)
}
texts := textParts(rc.assistant)
var kept, notice bool
var kept bool
notice := false
for _, p := range texts {
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, "调用失败") && !hasProcessNote(rc.assistant, "未独立配置") {
// 2026-10-10 契约更新:验证器不可用不再作为正文 Notice,落成过程检查行。
if !kept || notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") {
t.Fatalf("parts = %+v", texts)
}
})
+7 -2
View File
@@ -45,8 +45,13 @@ func TestVerifyCapNoticeAfterThreeContinuations(t *testing.T) {
}
texts := textParts(rc.assistant)
last := texts[len(texts)-1]
if last.Process || !strings.Contains(last.Text, "本轮回复未通过终轮检查") {
t.Fatalf("final notice = %+v, want non-process verify cap notice", last)
// 2026-10-10 契约更新:封顶提示不再作为正文 Notice;最后一次回复留在正式区,
// 封顶事实以未通过态检查行收进过程区。
if last.Process || last.Text != "答案 v4。" {
t.Fatalf("final text = %+v, want the model's own last reply", last)
}
if !hasProcessNote(rc.assistant, "本轮回复未通过终轮检查") {
t.Fatalf("cap check note missing: %+v", rc.assistant)
}
// 理由经 CheckPart 进过程区:3 次续跑各记一次,达上限时再记一次(与旧行为一致)。
notes := 0