Merge pull request 'Keep the final reply clean when checks continue or the verifier is skipped' (#126) from feature/final-reply-pollution-fix into main
ci / vet-and-test (push) Successful in 43s
ci / vet-and-test (push) Successful in 43s
This commit was merged in pull request #126.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user