Compose a separate final reply after the work loop #49

Merged
weichen merged 3 commits from feature/final-reply-compose into main 2026-10-02 14:15:17 +00:00
11 changed files with 580 additions and 31 deletions
@@ -0,0 +1,16 @@
---
id: aux.compose.final
layer: aux
version: 1
title: 终轮回复整理
description: 不带工具,把工作结果整理成直接回答用户的一段话
---
你负责写给用户的最终回复。用户只看这一段,看不到上面的工作过程。
规则:
- 只输出给用户看的一段话。不要写思考过程,不要使用 think 标签,不要写「根据规则」「所以我应该」。
- 直接回应用户的原始请求。先写结果和结论,再写交付了什么、要点是什么、还可以怎么调整。
- 不要复述过程,不要提内部检查、lint、重试、验证器、评分、文件路径、字节数或工件编号。
- 检查结论只用来判断有没有没做完的事。文件已经按用户的要求生成时,不要把评分或版式意见告诉用户。
- 只有用户的请求确实没完成时才说明,用用户能懂的话,不要引用检查原文。
- 不要编造材料里没有的内容。使用与用户相同的语言。
@@ -9,6 +9,8 @@ description: 不带工具,按证据清单判断终轮是否完成,只输出
规则:
- 只根据证据判断。回复里声称生成了文件,但工件清单里没有,passed 必须为 false。
- 同一对象的后一次检查覆盖前一次。例如同一文件先 lint 报错,改完再 lint 只剩 warning,以最后一次为准。
- 内置质量检查已通过时,不要因为更早的工具输出里出现过 error 就判失败。
- 拿不准时 passed 必须为 false。
- reason 写给复核的人看,说明通过或不通过的依据。
- nextAction 写给主模型:不通过时给出下一步该做什么;通过时留空字符串。
+138
View File
@@ -0,0 +1,138 @@
package runtime
import (
"fmt"
"regexp"
"strings"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// 工件编号和字节数只属于系统记录。收尾模型见过工具摘要后会把它们抄进回复。
var internalRef = regexp.MustCompile(`art_[0-9a-f]{8,}|\d[\d,]*\s*字节`)
const composeFallbackPrompt = `你负责写给用户的最终回复。用户只看这一段,看不到上面的工作过程。
规则:
- 只输出给用户看的一段话。不要写思考过程,不要使用 think 标签,不要写「根据规则」「所以我应该」。
- 直接回应用户的原始请求。先写结果和结论,再写交付了什么、要点是什么、还可以怎么调整。
- 不要复述过程,不要提内部检查、lint、重试、验证器、评分、文件路径、字节数或工件编号。
- 检查结论只用来判断有没有没做完的事。文件已经按用户的要求生成时,不要把评分或版式意见告诉用户。
- 只有用户的请求确实没完成时才说明,用用户能懂的话,不要引用检查原文。
- 不要编造材料里没有的内容。使用与用户相同的语言。`
func (l *LLMRunner) composePrompt(owner string) string {
if l.Prompts != nil {
if body, ok, err := l.Prompts.ModuleBody(owner, "aux.compose.final"); err == nil && ok && strings.TrimSpace(body) != "" {
return body
}
}
return composeFallbackPrompt
}
// composeFinal 在工作循环结束后,用主模型单独写一段直接回答用户的回复。
// 失败或空结果时恢复草稿,不影响这一轮已经成功结束。
func (st *directRun) composeFinal(stream agentllm.StreamFunc, model, notice string, opts agentllm.StreamOptions) bool {
_ = st.rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseFinalizing})
draftIdx := st.demoteLastText()
draft := ""
if draftIdx >= 0 {
draft = st.finalParts[draftIdx].Text
}
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: st.l.composePrompt(st.rc.Profile().Owner)},
{Role: agentllm.RoleUser, Content: st.composeUserPrompt(draft, notice)},
}
var b strings.Builder
failed := false
for ev := range stream(st.rc.Context(), model, msgs, nil, opts) {
switch ev.Kind {
case agentllm.KindTextDelta:
b.WriteString(ev.Text)
case agentllm.KindError:
failed = true
case agentllm.KindMessageEnd:
st.usageTotal.PromptTokens += ev.Usage.PromptTokens
st.usageTotal.CompletionTokens += ev.Usage.CompletionTokens
st.usageTotal.TotalTokens += ev.Usage.TotalTokens
if b.Len() == 0 {
b.WriteString(ev.Message.Content)
}
}
}
// 有的模型把推理放进 think 再写正文。推理不发给用户;只发出剥离后的正文。
body, _ := splitThinkBlock(b.String())
body = scrubInternal(body)
if failed || body == "" || strings.Contains(body, "<think>") {
if draftIdx >= 0 && draftIdx < len(st.finalParts) && st.finalParts[draftIdx].Type == core.PartText {
st.finalParts[draftIdx].Process = false
}
return false
}
_ = st.rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: st.textIdx, Text: body})
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: body})
st.textIdx++
return true
}
func (st *directRun) composeUserPrompt(draft, notice string) string {
var b strings.Builder
b.WriteString("用户的原始请求:\n")
if user := strings.TrimSpace(currentUserText(st.rc)); user == "" {
b.WriteString("(无)\n")
} else {
b.WriteString(user)
b.WriteString("\n")
}
b.WriteString("\n工作过程中的草稿回复(不要照抄过程,把它整理成给用户的回复):\n")
if strings.TrimSpace(draft) == "" {
b.WriteString("(无)\n")
} else {
b.WriteString(scrubInternal(draft))
b.WriteString("\n")
}
b.WriteString("\n工具过程:\n")
if len(st.evidence) == 0 {
b.WriteString("(无)\n")
}
for i, ev := range st.evidence {
fmt.Fprintf(&b, "#%d %s %s %s\n", i+1, ev.Name, ev.Status, scrubInternal(ev.Summary))
}
b.WriteString("\n交付物:\n")
names := 0
for _, ref := range st.deliverables {
if ref == nil || strings.TrimSpace(ref.Name) == "" {
continue
}
fmt.Fprintf(&b, "- %s\n", ref.Name)
names++
}
if names == 0 {
b.WriteString("(无)\n")
}
b.WriteString("\n检查结论:\n")
if len(st.checks) == 0 {
b.WriteString("(无)\n")
}
for _, c := range st.checks {
if c.Passed {
fmt.Fprintf(&b, "- 通过:%s\n", scrubInternal(c.Reason))
continue
}
fmt.Fprintf(&b, "- 未通过:%s\n", scrubInternal(c.Reason))
}
if strings.TrimSpace(notice) != "" {
b.WriteString("\n仍未完成(必须在回复里如实说明,不要假装已经完成):\n")
b.WriteString(scrubInternal(notice))
b.WriteString("\n")
}
return b.String()
}
func scrubInternal(s string) string {
s = internalRef.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, "()", "")
s = strings.ReplaceAll(s, "()", "")
return strings.TrimSpace(s)
}
+334
View File
@@ -0,0 +1,334 @@
package runtime
import (
"context"
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
func TestEvidencePromptLastCheckWins(t *testing.T) {
paper := `[{"severity":"error","rule":"paper-size","message":"纸张不是 A4(21.59 × 27.94 cm)"}]`
empty := `[{"severity":"error","rule":"empty-section","message":"标题下没有内容"}]`
warn := `[{"severity":"warning","rule":"visual-summary","message":"建议添加视觉摘要"}]`
evidence := []toolEvidence{
{Name: "run_shell", Status: "ok", Summary: summarize(paper, nil), VerifyText: tailRunes(paper, 600)},
{Name: "run_shell", Status: "ok", Summary: summarize(empty, nil), VerifyText: tailRunes(empty, 600)},
{Name: "run_shell", Status: "ok", Summary: summarize(warn, nil), VerifyText: tailRunes(warn, 600)},
}
got := evidencePrompt("给我总结一份会议纪要,以 Word 文档给我", "lint 无 error", evidence, []*core.ArtifactRef{
{ArtifactID: "art_abc79b0e4486", Name: "纪要.docx"},
})
for _, want := range []string{"#1 ", "#2 ", "#3 ", "以最后一次为准", "visual-summary", "这是强证据", "纸张不是 A4"} {
if !strings.Contains(got, want) {
t.Fatalf("prompt missing %q:\n%s", want, got)
}
}
if strings.Index(got, "paper-size") > strings.Index(got, "visual-summary") {
t.Fatal("later check should follow the earlier one")
}
long := strings.Repeat("纸张不是 A4。", 200) + "TAIL_MARKER"
tail := tailRunes(long, 600)
if !strings.HasPrefix(tail, "…") || !strings.HasSuffix(tail, "TAIL_MARKER") || strings.HasPrefix(tail, "纸张") {
t.Fatalf("tail did not keep the end: %q", tail[:40])
}
}
func TestVerifyRejectionIsNotUserSpeech(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("答案是 42。"),
textDeltaTurn("答案仍是 42。"),
}
var seen []string
base := scriptStream(turns, nil)
r := &LLMRunner{
Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
seen = append(seen, joinContents(msgs))
return base(ctx, model, msgs, tools, opts)
},
VerifyStream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
body := `{"passed":false,"reason":"纸张不是 A4","nextAction":"改成 A4"}`
if len(seen) > 1 {
body = `{"passed":true,"reason":"完成","nextAction":""}`
}
return passedStream(body)(ctx, model, msgs, tools, opts)
},
}
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if len(seen) < 2 {
t.Fatalf("calls = %d", len(seen))
}
second := seen[1]
if !strings.Contains(second, "<system-reminder>") || !strings.Contains(second, "不是用户发言") || !strings.Contains(second, "改成 A4") {
t.Fatalf("rejection was not wrapped: %s", second)
}
if strings.Contains(second, "您提到") {
t.Fatalf("rejection speaks as the user: %s", second)
}
}
func TestComposeReplacesDraftAndPinsArtifact(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("我来写文档。", toolCall("make_report", "{}")),
textDeltaTurn("草稿:已完成,lint 无 error。"),
textDeltaTurn("会议纪要已写好,文件在下面。"),
}
var toolCounts []int
base := scriptStream(turns, nil)
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
toolCounts = append(toolCounts, len(tools))
return base(ctx, model, msgs, tools, opts)
}}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
compose: true,
execArtifact: &core.Artifact{ID: "art_abc79b0e4486", Name: "纪要.docx"},
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if len(toolCounts) != 3 || toolCounts[2] != 0 {
t.Fatalf("tool decl counts = %v, want the compose call to have no tools", toolCounts)
}
sawFinalizing := false
deltaAfter := false
for _, e := range rc.events {
if e.typ == core.EvRunPhase {
p, ok := e.payload.(core.RunPhasePayload)
if ok && p.Phase == core.PhaseFinalizing {
sawFinalizing = true
}
}
if sawFinalizing && e.typ == core.EvMessageDelta {
p, ok := e.payload.(core.MessageDeltaPayload)
if ok && strings.Contains(p.Text, "会议纪要已写好") {
deltaAfter = true
}
}
}
if !sawFinalizing || !deltaAfter {
t.Fatal("finalizing phase did not precede the compose deltas")
}
parts := rc.assistant
if parts[len(parts)-1].Type != core.PartArtifactRef || parts[len(parts)-1].Artifact.Name != "纪要.docx" {
t.Fatalf("last part = %+v", parts[len(parts)-1])
}
var draft, reply *core.Part
for i := range parts {
p := &parts[i]
if p.Type != core.PartText {
continue
}
if strings.Contains(p.Text, "草稿") {
draft = p
}
if strings.Contains(p.Text, "会议纪要已写好") {
reply = p
}
}
if draft == nil || !draft.Process || reply == nil || reply.Process {
t.Fatalf("draft=%+v reply=%+v", draft, reply)
}
}
func TestComposeStripsThinkBlock(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("我来写。", toolCall("make_report", "{}")),
textDeltaTurn("草稿:写好了。"),
textDeltaTurn("<think>\n根据规则,所以我应该解释评分。\n</think>\n\n纪要已写好,文件在下面。"),
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
compose: true,
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
var reply string
for _, p := range rc.assistant {
if p.Type == core.PartText && !p.Process {
reply = p.Text
}
if p.Type == core.PartText && strings.Contains(p.Text, "所以我应该") {
t.Fatalf("think leaked into parts: %+v", p)
}
}
if reply != "纪要已写好,文件在下面。" {
t.Fatalf("reply = %q", reply)
}
}
func TestComposeReplyDropsInternalRefs(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("我来写。", toolCall("make_report", "{}")),
textDeltaTurn("草稿。"),
textDeltaTurn("纪要已写好。登记信息:art_986876932a39(38,505 字节)。"),
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
compose: true,
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
var reply string
for _, p := range rc.assistant {
if p.Type == core.PartText && !p.Process {
reply = p.Text
}
}
if strings.Contains(reply, "art_") || strings.Contains(reply, "字节") || !strings.Contains(reply, "纪要已写好") {
t.Fatalf("reply = %q", reply)
}
}
func TestComposeFailureRestoresDraft(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("", toolCall("make_report", "{}")),
textDeltaTurn("草稿还在。"),
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
compose: true,
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
var draft *core.Part
for i := range rc.assistant {
p := &rc.assistant[i]
if p.Type == core.PartText && strings.Contains(p.Text, "草稿还在") {
draft = p
}
if p.Type == core.PartText && strings.Contains(p.Text, "整理") {
t.Fatalf("unexpected compose text: %+v", p)
}
}
if draft == nil || draft.Process {
t.Fatalf("draft was not restored: %+v", rc.assistant)
}
}
func TestComposeSkipsPlainChatAndCanBeDisabled(t *testing.T) {
plain := [][]agentllm.Event{textDeltaTurn("你好。")}
r := &LLMRunner{Stream: scriptStream(plain, nil)}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
compose: true,
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if hasPhase(rc, core.PhaseFinalizing) {
t.Fatal("plain chat should not finalize")
}
worked := [][]agentllm.Event{
textDeltaTurn("我来写。", toolCall("make_report", "{}")),
textDeltaTurn("写好了。"),
}
r = &LLMRunner{Stream: scriptStream(worked, nil)}
rc = &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if hasPhase(rc, core.PhaseFinalizing) {
t.Fatal("compose off should not finalize")
}
found := false
for _, p := range rc.assistant {
if p.Type == core.PartText && p.Text == "写好了。" && !p.Process {
found = true
}
}
if !found {
t.Fatalf("draft missing: %+v", rc.assistant)
}
}
func TestComposeAbsorbsVerifierNotice(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("我先生成。", toolCall("make_report", "{}")),
textDeltaTurn("写好了。"),
textDeltaTurn("文件已生成,请查看。"),
}
r := &LLMRunner{
Stream: scriptStream(turns, nil),
VerifyStream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
out := make(chan agentllm.Event, 1)
out <- agentllm.Event{Kind: agentllm.KindError, ErrorMessage: "upstream"}
close(out)
return out
},
}
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true, compose: true}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
for _, p := range rc.assistant {
if p.Type == core.PartText && strings.Contains(p.Text, "验证器不可用") {
t.Fatalf("notice was appended beside the compose reply: %+v", rc.assistant)
}
}
if !hasProcessNote(rc.assistant, "调用失败") {
t.Fatalf("check missing: %+v", rc.assistant)
}
found := false
for _, p := range rc.assistant {
if p.Type == core.PartText && p.Text == "文件已生成,请查看。" && !p.Process {
found = true
}
}
if !found {
t.Fatalf("compose reply missing: %+v", rc.assistant)
}
}
func TestComposeUserPromptOmitsArtifactIDs(t *testing.T) {
st := &directRun{
rc: &fakeRunCtx{ctx: context.Background(), history: []core.Message{{
Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "给我一份纪要"}},
}}},
evidence: []toolEvidence{{Name: "register_artifact", Status: "ok", Summary: "已登记 art_abc79b0e4486,38505 字节"}},
deliverables: []*core.ArtifactRef{{ArtifactID: "art_abc79b0e4486", Name: "纪要.docx"}},
checks: []core.CheckPart{{Passed: false, Reason: "纸张尺寸"}},
}
got := st.composeUserPrompt("草稿里写了 art_abc79b0e4486(38,505 字节)", "本轮回复未通过终轮检查。")
if strings.Contains(got, "art_") || strings.Contains(got, "字节") {
t.Fatalf("prompt leaked an internal ref: %s", got)
}
for _, want := range []string{"给我一份纪要", "纪要.docx", "纸张尺寸", "仍未完成", "本轮回复未通过终轮检查。"} {
if !strings.Contains(got, want) {
t.Fatalf("prompt missing %q:\n%s", want, got)
}
}
}
func hasPhase(rc *fakeRunCtx, phase core.RunPhase) bool {
for _, e := range rc.events {
if e.typ != core.EvRunPhase {
continue
}
p, ok := e.payload.(core.RunPhasePayload)
if ok && p.Phase == phase {
return true
}
}
return false
}
+9
View File
@@ -451,6 +451,15 @@ func truncateRun(s string, n int) string {
return string(r[:n]) + "…"
}
// tailRunes 取尾部,给验证器看同一次命令的最后结果,而不是被截掉的开头。
func tailRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return "…" + string(r[len(r)-n:])
}
// splitThinkBlock 剥离正文里内联的 <think>…</think> 推理块(部分 OpenAI 兼容
// 模型把思考混进 content 流):返回(干净正文, 思考内容)。只处理首部成对标签;
// 未闭合或非首部出现按原文返回(宁可多显示也不误删正文)。
+53 -20
View File
@@ -48,21 +48,24 @@ type directRun struct {
checklistTouched bool
toolTurnsSince int
checklistReminders int
checks []core.CheckPart
}
type toolEvidence struct {
Name string
Status string
Summary string
Name string
Status string
Summary string
VerifyText string // 给验证器的尾部摘要,比界面上的摘要长
}
type toolSlot struct {
call agentllm.ToolCall
start time.Time
summary string
status string
truncated bool
artifact *core.ArtifactRef
call agentllm.ToolCall
start time.Time
summary string
verifyText string
status string
truncated bool
artifact *core.ArtifactRef
}
type directTool struct {
@@ -156,7 +159,11 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
return st.runTool(call.Name, call.Arguments)
},
}, user)
if res.Notice != "" {
composed := false
if err == nil && res.Err == nil && len(st.evidence) > 0 && st.eff.GetBool("runtime.composeFinal", true) {
composed = st.composeFinal(stream, model, res.Notice, streamOpts)
}
if res.Notice != "" && !composed {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: res.Notice})
}
if err == nil && res.Err == nil && len(st.finalParts) == 0 {
@@ -277,7 +284,7 @@ func (st *directRun) onEvent(ev nruntime.Event) {
if slot.artifact != nil {
st.deliverables = append(st.deliverables, slot.artifact)
}
st.evidence = append(st.evidence, toolEvidence{Name: slot.call.Name, Status: slot.status, Summary: slot.summary})
st.evidence = append(st.evidence, toolEvidence{Name: slot.call.Name, Status: slot.status, Summary: slot.summary, VerifyText: slot.verifyText})
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated, DurationMs: durationMs(slot.start, end)})
st.inflight = nil
}
@@ -290,7 +297,7 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
st.addVerifyNote(why)
}
if info.Continuations < maxDeliverableGuards {
return nruntime.StopDecision{Continue: true, Replacement: discardedClaimNote, Reason: deliverableGuardNote}
return nruntime.StopDecision{Continue: true, Replacement: discardedClaimNote, Reason: systemCheckReason(deliverableGuardNote)}
}
return nruntime.StopDecision{Notice: deliverableFallbackNotice}
}
@@ -315,31 +322,56 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
}
return st.continueOrNotice(info, reason)
}
st.addCheck(true, strings.TrimSpace(verdict.Reason))
return nruntime.StopDecision{}
}
func (st *directRun) demoteLastText() {
// demoteLastText 把最近一段还没标成过程的正文降级。已经是过程的解说不动。
func (st *directRun) demoteLastText() int {
for i := len(st.finalParts) - 1; i >= 0; i-- {
if st.finalParts[i].Type == core.PartText {
if st.finalParts[i].Type == core.PartText && !st.finalParts[i].Process {
st.finalParts[i].Process = true
return
return i
}
}
return -1
}
func (st *directRun) addVerifyNote(reason string) {
st.finalParts = append(st.finalParts, core.Part{
Type: core.PartText,
st.addCheck(false, reason)
}
// addCheck 记下终轮检查并发出 run.check。有尚未降级的回复时,插在它前面,
// 这样检查行留在过程区,回复留在过程区外面。
func (st *directRun) addCheck(passed bool, reason string) {
part := core.Part{
Type: core.PartCheck,
Process: true,
Text: "(验证未通过:" + reason + ")",
})
Check: &core.CheckPart{Passed: passed, Reason: reason},
}
idx := -1
for i := len(st.finalParts) - 1; i >= 0; i-- {
if st.finalParts[i].Type == core.PartText && !st.finalParts[i].Process {
idx = i
break
}
}
if idx >= 0 {
st.finalParts = append(st.finalParts, core.Part{})
copy(st.finalParts[idx+1:], st.finalParts[idx:])
st.finalParts[idx] = part
} else {
st.finalParts = append(st.finalParts, part)
}
st.checks = append(st.checks, core.CheckPart{Passed: passed, Reason: reason})
_ = st.rc.Emit(core.EvRunCheck, core.RunCheckPayload{Passed: passed, Reason: reason})
}
func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nruntime.StopDecision {
if info.Continuations < maxVerifyContinuations {
st.demoteLastText()
st.addVerifyNote(reason)
return nruntime.StopDecision{Continue: true, Reason: reason}
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason(reason)}
}
st.addVerifyNote(reason)
return nruntime.StopDecision{Notice: verifyCapNotice}
@@ -463,6 +495,7 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
if st.inflight != nil {
st.inflight.summary = summary
st.inflight.status = status
st.inflight.verifyText = tailRunes(out, 600)
}
if len(out) > 16000 {
out = out[:16000] + "…(超长截断)"
+2 -3
View File
@@ -33,6 +33,7 @@ type fakeRunCtx struct {
toolRuns int // ExecuteTool 被调用的次数
execArtifactContent []byte // Artifact(id) 回填内容(ctxasm D46 断言用)
verify bool // runtime.verify;缺省关闭,避免改变既有流调用次数
compose bool // runtime.composeFinal;缺省关闭,避免多一次主模型调用
missingArtifact bool // 工具登记了工件,但 Artifact 查不到
emptyArtifact bool // Artifact 查到了,内容为空
history []core.Message // 非 nil 时覆盖默认历史
@@ -49,9 +50,7 @@ func (f *fakeRunCtx) Settings() core.EffectiveSettings {
if f.cardflow {
eff["ui"] = map[string]any{"cardflow": true}
}
if f.verify {
eff["runtime"] = map[string]any{"verify": true}
}
eff["runtime"] = map[string]any{"verify": f.verify, "composeFinal": f.compose}
return eff
}
func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error {
+4 -4
View File
@@ -172,11 +172,11 @@ func TestVerifierRejectsThenPasses(t *testing.T) {
}
}
texts := textParts(rc.assistant)
if len(texts) < 3 || !texts[0].Process || !strings.Contains(texts[1].Text, "验证未通过") || texts[len(texts)-1].Process {
if len(texts) < 2 || !texts[0].Process || texts[len(texts)-1].Process {
t.Fatalf("parts = %+v", texts)
}
if !strings.Contains(texts[1].Text, "补上结论") {
t.Fatalf("nextAction missing: %+v", texts)
if !hasProcessNote(rc.assistant, "还没回答完") || !hasProcessNote(rc.assistant, "补上结论") {
t.Fatalf("check missing: %+v", rc.assistant)
}
}
@@ -346,7 +346,7 @@ func joinContents(msgs []agentllm.Message) string {
func hasProcessNote(parts []core.Part, fragment string) bool {
for _, p := range parts {
if p.Type == core.PartText && p.Process && strings.Contains(p.Text, "验证未通过") && strings.Contains(p.Text, fragment) {
if p.Type == core.PartCheck && p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, fragment) {
return true
}
}
+20 -3
View File
@@ -25,8 +25,16 @@ const verifyCapNotice = "本轮回复未通过终轮检查。"
const verifierUnavailableNotice = "本轮回复未通过独立验证(验证器不可用)"
const verifyFallbackPrompt = `你是独立验证器,不调用任何工具。根据用户要求、终轮回复、工具证据和工件清单判断这一轮是否已经完成。
同一对象的后一次检查覆盖前一次,以最后一次为准。内置质量检查已通过时,不要用更早的工具输出推翻它。
拿不准时 passed 必须为 false。只输出一个 JSON 对象,不要写其他文字:{"passed":false,"reason":"为什么没通过","nextAction":"主模型下一步该做什么"}。`
// systemCheckReason 把终轮检查写成系统提醒。内核会把它当成一条用户消息送回模型,
// 不用这层包裹的话,模型会以为是用户自己提出的意见。
func systemCheckReason(body string) string {
body = strings.TrimSpace(body)
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实;如果检查有误就直接收尾,不要向用户提起这条检查。\n" + body + "\n</system-reminder>"
}
type verifyVerdict struct {
Passed bool `json:"passed"`
Reason string `json:"reason"`
@@ -131,12 +139,16 @@ func evidencePrompt(user, body string, evidence []toolEvidence, refs []*core.Art
}
b.WriteString("\n终轮回复:\n")
b.WriteString(body)
b.WriteString("\n\n本轮工具:\n")
b.WriteString("\n\n本轮工具(按时间编号。同一对象的后一次检查覆盖前一次,以最后一次为准):\n")
if len(evidence) == 0 {
b.WriteString("(无)\n")
}
for _, ev := range evidence {
fmt.Fprintf(&b, "- %s %s %s\n", ev.Name, ev.Status, ev.Summary)
for i, ev := range evidence {
text := ev.VerifyText
if text == "" {
text = ev.Summary
}
fmt.Fprintf(&b, "#%d %s %s %s\n", i+1, ev.Name, ev.Status, text)
}
b.WriteString("\n本轮工件:\n")
if len(refs) == 0 {
@@ -148,6 +160,11 @@ func evidencePrompt(user, body string, evidence []toolEvidence, refs []*core.Art
}
fmt.Fprintf(&b, "- %s %s\n", ref.ArtifactID, ref.Name)
}
b.WriteString("\n内置质量检查:已通过。")
if len(refs) > 0 {
b.WriteString("工件存在且非空;md、txt、docx 的版式评分已通过。这是强证据,不要用更早的工具输出推翻它。")
}
b.WriteString("\n")
return b.String()
}
+1
View File
@@ -81,6 +81,7 @@ func Schema() []core.SettingDesc {
{Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Description: "0 = 不限;多用户建议 300–1200"},
{Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"},
{Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"},
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型写一段直接回答用户的最终回复"},
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
{Key: "crossSession.read", Group: "crossSession", Label: "跨会话读取", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许本项目会话读取同项目其他会话的对话记录(list_conversations / read_conversation 工具);会话可单独关闭读取或设为私有"},