diff --git a/deps/agent-system-core b/deps/agent-system-core index f08a4a9..7c66075 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit f08a4a90c63525401671231e262fd6c45e9c1bff +Subproject commit 7c66075ee9caa9f990d6d6ad7619f7af8d9040e8 diff --git a/internal/prompts/library/aux-compose-final.md b/internal/prompts/library/aux-compose-final.md new file mode 100644 index 0000000..5dd5b16 --- /dev/null +++ b/internal/prompts/library/aux-compose-final.md @@ -0,0 +1,16 @@ +--- +id: aux.compose.final +layer: aux +version: 1 +title: 终轮回复整理 +description: 不带工具,把工作结果整理成直接回答用户的一段话 +--- +你负责写给用户的最终回复。用户只看这一段,看不到上面的工作过程。 + +规则: +- 只输出给用户看的一段话。不要写思考过程,不要使用 think 标签,不要写「根据规则」「所以我应该」。 +- 直接回应用户的原始请求。先写结果和结论,再写交付了什么、要点是什么、还可以怎么调整。 +- 不要复述过程,不要提内部检查、lint、重试、验证器、评分、文件路径、字节数或工件编号。 +- 检查结论只用来判断有没有没做完的事。文件已经按用户的要求生成时,不要把评分或版式意见告诉用户。 +- 只有用户的请求确实没完成时才说明,用用户能懂的话,不要引用检查原文。 +- 不要编造材料里没有的内容。使用与用户相同的语言。 diff --git a/internal/prompts/library/aux-verify-final.md b/internal/prompts/library/aux-verify-final.md index 9c034fd..667aa4e 100644 --- a/internal/prompts/library/aux-verify-final.md +++ b/internal/prompts/library/aux-verify-final.md @@ -9,6 +9,8 @@ description: 不带工具,按证据清单判断终轮是否完成,只输出 规则: - 只根据证据判断。回复里声称生成了文件,但工件清单里没有,passed 必须为 false。 +- 同一对象的后一次检查覆盖前一次。例如同一文件先 lint 报错,改完再 lint 只剩 warning,以最后一次为准。 +- 内置质量检查已通过时,不要因为更早的工具输出里出现过 error 就判失败。 - 拿不准时 passed 必须为 false。 - reason 写给复核的人看,说明通过或不通过的依据。 - nextAction 写给主模型:不通过时给出下一步该做什么;通过时留空字符串。 diff --git a/internal/runtime/compose.go b/internal/runtime/compose.go new file mode 100644 index 0000000..01b33ca --- /dev/null +++ b/internal/runtime/compose.go @@ -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, "") { + 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) +} diff --git a/internal/runtime/compose_test.go b/internal/runtime/compose_test.go new file mode 100644 index 0000000..8bbfb06 --- /dev/null +++ b/internal/runtime/compose_test.go @@ -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, "") || !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("\n根据规则,所以我应该解释评分。\n\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 +} diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 927e0f9..2a22796 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -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 剥离正文里内联的 … 推理块(部分 OpenAI 兼容 // 模型把思考混进 content 流):返回(干净正文, 思考内容)。只处理首部成对标签; // 未闭合或非首部出现按原文返回(宁可多显示也不误删正文)。 diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index f1d7fd9..8d1a1ab 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -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] + "…(超长截断)" diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index 55a841a..0f81102 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -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 { diff --git a/internal/runtime/llm_verify_test.go b/internal/runtime/llm_verify_test.go index b95d5e0..141b570 100644 --- a/internal/runtime/llm_verify_test.go +++ b/internal/runtime/llm_verify_test.go @@ -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 } } diff --git a/internal/runtime/verify.go b/internal/runtime/verify.go index a3af8de..5a8ec01 100644 --- a/internal/runtime/verify.go +++ b/internal/runtime/verify.go @@ -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 "\n这是系统的终轮自动检查,不是用户发言。先用工具核实;如果检查有误就直接收尾,不要向用户提起这条检查。\n" + body + "\n" +} + 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() } diff --git a/internal/settings/settings.go b/internal/settings/settings.go index a7f2415..2c34ecf 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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 工具);会话可单独关闭读取或设为私有"},