Compose a separate final reply after the work loop #49
Vendored
+1
-1
Submodule deps/agent-system-core updated: f08a4a90c6...7c66075ee9
@@ -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 写给主模型:不通过时给出下一步该做什么;通过时留空字符串。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 流):返回(干净正文, 思考内容)。只处理首部成对标签;
|
||||
// 未闭合或非首部出现按原文返回(宁可多显示也不误删正文)。
|
||||
|
||||
@@ -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] + "…(超长截断)"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 工具);会话可单独关闭读取或设为私有"},
|
||||
|
||||
Reference in New Issue
Block a user