Consume the sunk gate mechanics and pin the WP5 library (N1 WP5/WP6) #72
Vendored
+1
-1
Submodule deps/agent-native updated: c5b9dca446...c9c2f23990
+35
-40
@@ -5,16 +5,17 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
nruntime "agent-native/runtime"
|
||||
"agent-native/runtime/gates"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-native/runtime/policy"
|
||||
agenttools "agent-native/runtime/tools"
|
||||
"agent-system-backend/internal/ctxasm"
|
||||
"agent-system-backend/internal/prompts"
|
||||
"agent-system-backend/internal/quality"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -453,61 +454,55 @@ func orDefault(s, def string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
const maxDeliverableGuards = 2
|
||||
|
||||
const deliverableGuardNote = "你的上一段回复声称已经生成或登记了文件,但这一轮没有产生对应的工件。请二选一:用 run_python 写出文件并调用 register_artifact 登记,或如实说明这一轮没有生成文件。不要编造工件 ID,也不要在回复里写 art_ 开头的编号。正文里的「调用工具」「工具结果」「产出工件」字样不会执行任何东西,调用工具必须用函数调用。"
|
||||
|
||||
const discardedClaimNote = "(上一段回复声称已交付,但本轮没有任何工具调用,已作废)"
|
||||
|
||||
const deliverableFallbackNotice = "本轮未实际生成文件(模型声称已生成,但没有调用工具)。请重试,或换一种说法再试一次。"
|
||||
|
||||
var (
|
||||
artIDRe = regexp.MustCompile(`art_[0-9a-f]{12}`)
|
||||
fileClaimRe = regexp.MustCompile(`(?s)(?:已生成|已保存|已导出|已写入).{0,120}?\.(?:docx|xlsx|pptx|pdf|png|jpe?g|csv|md|txt|json)\b`)
|
||||
softClaimRe = regexp.MustCompile(`(?s)(?:文件|文档|工件).{0,40}?已(?:正常)?(?:生成|登记|保存|导出|写入)`)
|
||||
forgedCallRe = regexp.MustCompile(`\[(?:调用工具|工具结果|产出工件) `)
|
||||
)
|
||||
|
||||
// claimsFalseDeliverable 保留给小队入口:命中即判失败,不走直连路径的纠正轮次。
|
||||
func claimsFalseDeliverable(body string, produced []*core.ArtifactRef, rc RunCtx) bool {
|
||||
block, _ := deliverableGate(body, produced, rc)
|
||||
block, _ := deliverableGateOf(body, produced, rc)
|
||||
return block
|
||||
}
|
||||
|
||||
// deliverableGate 判断终轮正文是否在没有对应工件的情况下声称交付。
|
||||
// 返回值的形状与 Stop 门禁一致,P1 平移进内核时不用再改调用方。
|
||||
// 引用本轮已产出、或项目里确实存在的工件,不算谎称;正文里的伪造调用标记始终拦截。
|
||||
func deliverableGate(body string, produced []*core.ArtifactRef, rc RunCtx) (bool, string) {
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return false, ""
|
||||
}
|
||||
if forgedCallRe.MatchString(body) {
|
||||
return true, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用"
|
||||
}
|
||||
producedIDs := map[string]bool{}
|
||||
for _, ref := range produced {
|
||||
if ref != nil && ref.ArtifactID != "" {
|
||||
producedIDs[ref.ArtifactID] = true
|
||||
}
|
||||
}
|
||||
knownExisting := false
|
||||
for _, id := range artIDRe.FindAllString(body, -1) {
|
||||
if producedIDs[id] {
|
||||
// deliverableGateOf 把产品工件存取投影成库端口后调用下沉的 ClaimGate(N1 WP5)。
|
||||
func deliverableGateOf(body string, produced []*core.ArtifactRef, rc RunCtx) (bool, string) {
|
||||
return gates.ClaimGate(body, artifactRefsOf(produced), artifactLookupOf(rc))
|
||||
}
|
||||
|
||||
// artifactRefsOf 把 core.ArtifactRef 投影为库的最小形态。
|
||||
func artifactRefsOf(refs []*core.ArtifactRef) []gates.ArtifactRef {
|
||||
out := make([]gates.ArtifactRef, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
if ref == nil || ref.ArtifactID == "" {
|
||||
continue
|
||||
}
|
||||
if _, _, ok := rc.Artifact(id); ok {
|
||||
knownExisting = true
|
||||
continue
|
||||
out = append(out, gates.ArtifactRef{ID: ref.ArtifactID, Name: ref.Name, Kind: ref.Kind})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// artifactLookupOf 把 rc.Artifact 投影为库的工件存取端口。
|
||||
func artifactLookupOf(rc RunCtx) gates.ArtifactLookup {
|
||||
return func(id string) (string, []byte, bool) {
|
||||
art, content, ok := rc.Artifact(id)
|
||||
if !ok {
|
||||
return "", nil, false
|
||||
}
|
||||
return true, "回复引用了不存在的工件"
|
||||
return art.Name, content, true
|
||||
}
|
||||
if len(producedIDs) > 0 || knownExisting {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// qualityScorer 把 quality.ScoreBytes 投影为库的评分端口。
|
||||
func qualityScorer() gates.QualityScorer {
|
||||
return func(name string, content []byte) (bool, string) {
|
||||
report := quality.ScoreBytes(name, content)
|
||||
if report.Passed {
|
||||
return true, ""
|
||||
}
|
||||
return false, strings.Join(report.Violations, ";")
|
||||
}
|
||||
if fileClaimRe.MatchString(body) || softClaimRe.MatchString(body) {
|
||||
return true, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func summarize(out string, err error) string {
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"time"
|
||||
|
||||
nruntime "agent-native/runtime"
|
||||
"agent-native/runtime/gates"
|
||||
"agent-native/runtime/hooks"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-native/runtime/reminders"
|
||||
agenttools "agent-native/runtime/tools"
|
||||
"agent-system-backend/internal/ctxasm"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
@@ -51,9 +51,8 @@ type directRun struct {
|
||||
closedToolTurn bool
|
||||
checklistTouched bool
|
||||
toolTurnsSince int
|
||||
checklistReminders int
|
||||
progress *gates.ProgressReminder
|
||||
checks []core.CheckPart
|
||||
notes *reminders.Bus
|
||||
servedModel string
|
||||
switches []string
|
||||
switchMu sync.Mutex
|
||||
@@ -348,12 +347,12 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
}
|
||||
|
||||
func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntime.StopDecision {
|
||||
if block, why := deliverableGate(st.turnBody, st.deliverables, st.rc); block {
|
||||
if block, why := deliverableGateOf(st.turnBody, st.deliverables, st.rc); block {
|
||||
st.demoteLastText()
|
||||
if st.eff.GetBool("runtime.verify", false) {
|
||||
st.addVerifyNote(why)
|
||||
}
|
||||
if info.Continuations < maxDeliverableGuards {
|
||||
if info.Continuations < gates.MaxDeliverableGuards {
|
||||
return nruntime.StopDecision{Continue: true, Replacement: discardedClaimNote, Reason: systemCheckReason(deliverableGuardNote)}
|
||||
}
|
||||
return nruntime.StopDecision{Notice: deliverableFallbackNotice}
|
||||
@@ -361,7 +360,8 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
|
||||
if !st.eff.GetBool("runtime.verify", false) {
|
||||
return nruntime.StopDecision{}
|
||||
}
|
||||
if reasons := checkDeliverables(st.rc, st.deliverables); len(reasons) > 0 {
|
||||
// N1 WP5:工件存在/非空/质量检查走下沉的 CheckArtifacts(端口在 llm.go 投影)。
|
||||
if reasons := gates.CheckArtifacts(artifactRefsOf(st.deliverables), artifactLookupOf(st.rc), qualityScorer()); len(reasons) > 0 {
|
||||
return st.continueOrNotice(info, strings.Join(reasons, ";"))
|
||||
}
|
||||
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, st.deliverables)
|
||||
@@ -425,7 +425,7 @@ func (st *directRun) addCheck(passed bool, reason string) {
|
||||
}
|
||||
|
||||
func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nruntime.StopDecision {
|
||||
if info.Continuations < maxVerifyContinuations {
|
||||
if info.Continuations < gates.MaxVerifyContinuations {
|
||||
st.demoteLastText()
|
||||
st.addVerifyNote(reason)
|
||||
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason(reason)}
|
||||
@@ -728,7 +728,7 @@ func (st *directRun) pinSubagentArtifacts(text string) {
|
||||
seen[ref.ArtifactID] = true
|
||||
}
|
||||
}
|
||||
for _, id := range artIDRe.FindAllString(text, -1) {
|
||||
for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/gates"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -230,11 +231,11 @@ func TestVerifierGarbageFailsClosed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseVerdictToleratesFence(t *testing.T) {
|
||||
got, err := parseVerdict("```json\n{\"passed\":false,\"reason\":\"不够\",\"nextAction\":\"重写\"}\n```")
|
||||
got, err := gates.ParseVerdict("```json\n{\"passed\":false,\"reason\":\"不够\",\"nextAction\":\"重写\"}\n```")
|
||||
if err != nil || got.Passed || got.Reason != "不够" {
|
||||
t.Fatalf("verdict = %+v %v", got, err)
|
||||
}
|
||||
if _, err := parseVerdict(`{"reason":"no passed"}`); err == nil {
|
||||
if _, err := gates.ParseVerdict(`{"reason":"no passed"}`); err == nil {
|
||||
t.Fatal("missing passed should fail")
|
||||
}
|
||||
}
|
||||
@@ -392,7 +393,7 @@ func TestFalseDeliverableFixtures(t *testing.T) {
|
||||
}
|
||||
for _, c := range doc.Cases {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
block, reason := deliverableGate(c.Body, nil, &fakeRunCtx{ctx: context.Background()})
|
||||
block, reason := deliverableGateOf(c.Body, nil, &fakeRunCtx{ctx: context.Background()})
|
||||
if !block || strings.TrimSpace(reason) == "" {
|
||||
t.Fatalf("gate = %v %q", block, reason)
|
||||
}
|
||||
|
||||
+23
-126
@@ -1,27 +1,15 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/gates"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-native/runtime/reminders"
|
||||
"agent-system-backend/internal/quality"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxVerifyContinuations 与内核 Stop 的 3 次上限对齐。
|
||||
maxVerifyContinuations = 3
|
||||
// reminderToolGap 连续这么多个工具轮没有更新计划或待办,就提醒一次。
|
||||
reminderToolGap = 3
|
||||
// maxChecklistReminders 每个 run 最多提醒这么多次。
|
||||
maxChecklistReminders = 3
|
||||
)
|
||||
|
||||
const verifyCapNotice = "本轮回复未通过终轮检查。"
|
||||
|
||||
const verifierUnavailableNotice = "本轮回复未通过独立验证(验证器不可用)"
|
||||
@@ -37,58 +25,12 @@ func systemCheckReason(body string) string {
|
||||
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实;如果检查有误就直接收尾,不要向用户提起这条检查。\n" + body + "\n</system-reminder>"
|
||||
}
|
||||
|
||||
type verifyVerdict struct {
|
||||
Passed bool `json:"passed"`
|
||||
Reason string `json:"reason"`
|
||||
NextAction string `json:"nextAction"`
|
||||
}
|
||||
|
||||
// checkDeliverables 确认本轮登记的工件存在且非空。
|
||||
// md、txt、docx 还要过 quality;其他类型只检查存在与非空。
|
||||
func checkDeliverables(rc RunCtx, refs []*core.ArtifactRef) []string {
|
||||
var reasons []string
|
||||
for _, ref := range refs {
|
||||
if ref == nil || ref.ArtifactID == "" {
|
||||
reasons = append(reasons, "工件引用缺少 ID")
|
||||
continue
|
||||
}
|
||||
art, content, ok := rc.Artifact(ref.ArtifactID)
|
||||
name := ref.Name
|
||||
if name == "" {
|
||||
name = string(ref.ArtifactID)
|
||||
}
|
||||
if !ok {
|
||||
reasons = append(reasons, name+" 不存在")
|
||||
continue
|
||||
}
|
||||
if art.Name != "" {
|
||||
name = art.Name
|
||||
}
|
||||
if len(content) == 0 {
|
||||
reasons = append(reasons, name+" 内容为空")
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".md", ".txt", ".docx":
|
||||
report := quality.ScoreBytes(name, content)
|
||||
if !report.Passed {
|
||||
why := strings.Join(report.Violations, ";")
|
||||
if why == "" {
|
||||
why = "评分未通过"
|
||||
}
|
||||
reasons = append(reasons, name+":"+why)
|
||||
}
|
||||
}
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (verifyVerdict, error) {
|
||||
func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (gates.Verdict, error) {
|
||||
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
|
||||
stream := l.VerifyStream
|
||||
if stream == nil {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return verifyVerdict{}, fmt.Errorf("验证器调用失败")
|
||||
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
|
||||
}
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
stream = client.Stream
|
||||
@@ -107,16 +49,16 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence,
|
||||
case agentllm.KindTextDelta:
|
||||
b.WriteString(ev.Text)
|
||||
case agentllm.KindError:
|
||||
return verifyVerdict{}, fmt.Errorf("验证器调用失败")
|
||||
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
|
||||
case agentllm.KindMessageEnd:
|
||||
if b.Len() == 0 {
|
||||
b.WriteString(ev.Message.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
verdict, err := parseVerdict(b.String())
|
||||
verdict, err := gates.ParseVerdict(b.String())
|
||||
if err != nil {
|
||||
return verifyVerdict{}, fmt.Errorf("验证器返回无法解析")
|
||||
return gates.Verdict{}, fmt.Errorf("验证器返回无法解析")
|
||||
}
|
||||
return verdict, nil
|
||||
}
|
||||
@@ -170,41 +112,6 @@ func evidencePrompt(user, body string, evidence []toolEvidence, refs []*core.Art
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func parseVerdict(raw string) (verifyVerdict, error) {
|
||||
s := stripFence(raw)
|
||||
start := strings.Index(s, "{")
|
||||
end := strings.LastIndex(s, "}")
|
||||
if start < 0 || end < start {
|
||||
return verifyVerdict{}, fmt.Errorf("验证器返回无法解析")
|
||||
}
|
||||
s = s[start : end+1]
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(s), &probe); err != nil {
|
||||
return verifyVerdict{}, err
|
||||
}
|
||||
if _, ok := probe["passed"]; !ok {
|
||||
return verifyVerdict{}, fmt.Errorf("验证器返回无法解析")
|
||||
}
|
||||
var verdict verifyVerdict
|
||||
if err := json.Unmarshal([]byte(s), &verdict); err != nil {
|
||||
return verifyVerdict{}, err
|
||||
}
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
func stripFence(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "```") {
|
||||
return s
|
||||
}
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "```"))
|
||||
return s
|
||||
}
|
||||
|
||||
func (st *directRun) loadChecklist() {
|
||||
if src, ok := st.rc.(interface{ Todos() (core.TodoList, bool) }); ok {
|
||||
if list, ok := src.Todos(); ok {
|
||||
@@ -230,49 +137,39 @@ func (st *directRun) reminders(turn int) []string {
|
||||
st.closedToolTurn = false
|
||||
st.checklistTouched = false
|
||||
}
|
||||
if st.toolTurnsSince < reminderToolGap || st.checklistReminders >= maxChecklistReminders {
|
||||
if st.toolTurnsSince < gates.ReminderToolGap {
|
||||
return nil
|
||||
}
|
||||
text := st.unfinishedReminder()
|
||||
if text == "" {
|
||||
return nil
|
||||
// N1 WP5:节流、封顶与文案走下沉的 ProgressReminder;
|
||||
// 工具轮计数留在这里(它与 directRun 的事件流耦合)。
|
||||
if st.progress == nil {
|
||||
st.progress = &gates.ProgressReminder{}
|
||||
}
|
||||
if st.notes == nil {
|
||||
st.notes = reminders.New(0, maxChecklistReminders)
|
||||
todos, plans := st.unfinishedItems()
|
||||
got := st.progress.Remind(todos, plans, time.Now())
|
||||
if got != nil {
|
||||
st.toolTurnsSince = 0
|
||||
}
|
||||
key := fmt.Sprintf("%d:%s", st.checklistReminders, text)
|
||||
if !st.notes.Admit(reminders.Note{Kind: reminders.KindTodo, Key: key, Text: text}, time.Now()) {
|
||||
return nil
|
||||
}
|
||||
st.toolTurnsSince = 0
|
||||
st.checklistReminders++
|
||||
return []string{text}
|
||||
return got
|
||||
}
|
||||
|
||||
func (st *directRun) unfinishedReminder() string {
|
||||
var lines []string
|
||||
// unfinishedItems 返回计划与待办的未完成项正文(状态过滤留在产品侧)。
|
||||
func (st *directRun) unfinishedItems() (todos, plans []string) {
|
||||
for _, item := range st.todos.Items {
|
||||
if item.Status == "done" {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(item.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
if text := strings.TrimSpace(item.Text); text != "" {
|
||||
todos = append(todos, text)
|
||||
}
|
||||
lines = append(lines, "- 待办:"+text)
|
||||
}
|
||||
for _, step := range st.plan.Steps {
|
||||
if step.Status == "done" || step.Status == "skipped" {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(step.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
if text := strings.TrimSpace(step.Text); text != "" {
|
||||
plans = append(plans, text)
|
||||
}
|
||||
lines = append(lines, "- 计划:"+text)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "计划和待办已连续 3 个工具轮没有更新。未完成项:\n" + strings.Join(lines, "\n") + "\n请更新计划或待办,或在回复里说明为什么先不做。"
|
||||
return todos, plans
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/gates"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-system-backend/internal/workflow"
|
||||
core "agent-system-core"
|
||||
@@ -77,7 +78,7 @@ func planWorkflow(rc RunCtx, goal string, runner WorkflowRunner) (workflowOutcom
|
||||
}
|
||||
|
||||
func parseWorkflowDef(raw string) (core.WorkflowDef, error) {
|
||||
s := stripFence(raw)
|
||||
s := stripFenceBlock(raw)
|
||||
start := strings.Index(s, "{")
|
||||
end := strings.LastIndex(s, "}")
|
||||
if start < 0 || end < start {
|
||||
@@ -335,7 +336,7 @@ func runPromptStep(rc RunCtx, step core.WorkflowStep) (string, []*core.ArtifactR
|
||||
func artifactsIn(rc RunCtx, text string) []*core.ArtifactRef {
|
||||
var out []*core.ArtifactRef
|
||||
seen := map[string]bool{}
|
||||
for _, id := range artIDRe.FindAllString(text, -1) {
|
||||
for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
@@ -349,13 +350,13 @@ func artifactsIn(rc RunCtx, text string) []*core.ArtifactRef {
|
||||
return out
|
||||
}
|
||||
|
||||
func (w WorkflowRunner) judgeStep(rc RunCtx, goal, criteria, output string) verifyVerdict {
|
||||
func (w WorkflowRunner) judgeStep(rc RunCtx, goal, criteria, output string) gates.Verdict {
|
||||
stream := w.JudgeStream
|
||||
model := "verify"
|
||||
if stream == nil {
|
||||
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return verifyVerdict{Passed: false, Reason: "验证器不可用", NextAction: "检查轻量模型配置"}
|
||||
return gates.Verdict{Passed: false, Reason: "验证器不可用", NextAction: "检查轻量模型配置"}
|
||||
}
|
||||
model = route.Model
|
||||
client := openLLMClient(route.Base, route.Key)
|
||||
@@ -368,16 +369,16 @@ func (w WorkflowRunner) judgeStep(rc RunCtx, goal, criteria, output string) veri
|
||||
case agentllm.KindTextDelta:
|
||||
b.WriteString(ev.Text)
|
||||
case agentllm.KindError:
|
||||
return verifyVerdict{Passed: false, Reason: "验证器不可用", NextAction: "稍后再试"}
|
||||
return gates.Verdict{Passed: false, Reason: "验证器不可用", NextAction: "稍后再试"}
|
||||
case agentllm.KindMessageEnd:
|
||||
if b.Len() == 0 {
|
||||
b.WriteString(ev.Message.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
verdict, err := parseVerdict(b.String())
|
||||
verdict, err := gates.ParseVerdict(b.String())
|
||||
if err != nil {
|
||||
return verifyVerdict{Passed: false, Reason: "无法解析", NextAction: "按验收标准重做"}
|
||||
return gates.Verdict{Passed: false, Reason: "无法解析", NextAction: "按验收标准重做"}
|
||||
}
|
||||
return verdict
|
||||
}
|
||||
@@ -421,3 +422,16 @@ func outcome(def core.WorkflowDef, statuses []string, arts []*core.ArtifactRef,
|
||||
}
|
||||
return workflowOutcome{Summary: parts[0].Text, Parts: parts, Artifacts: arts}
|
||||
}
|
||||
|
||||
// stripFenceBlock 剥模型输出的 ``` 围栏(工作流定义解析用;与门禁域无关,本地持有)。
|
||||
func stripFenceBlock(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "```") {
|
||||
return s
|
||||
}
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "```"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// wp5_ab_test.go 是 N1 WP5 验收的 A/B 对照:门禁机制下沉后,
|
||||
// 同一组脚本化模型输出的停止原因、提醒次数与工具拒绝必须与记录的
|
||||
// 旧行为逐条一致。既有套件(GuardTwiceThenNotice / VerifierRejectsThenPasses /
|
||||
// VerifierGarbageFailsClosed / TodoReminder 三测)在实现切换后原样通过,
|
||||
// 是性格化等价的主网;本文件补齐两个此前无覆盖的验收角度。
|
||||
|
||||
// TestVerifyCapNoticeAfterThreeContinuations:验证器持续拒绝时,
|
||||
// 续跑恰为 3 次(gates.MaxVerifyContinuations),随后以 verifyCapNotice 收尾,
|
||||
// 理由逐次进入过程区。
|
||||
func TestVerifyCapNoticeAfterThreeContinuations(t *testing.T) {
|
||||
turns := [][]agentllm.Event{
|
||||
textDeltaTurn("答案 v1。"),
|
||||
textDeltaTurn("答案 v2。"),
|
||||
textDeltaTurn("答案 v3。"),
|
||||
textDeltaTurn("答案 v4。"),
|
||||
}
|
||||
var mainCalls 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 {
|
||||
mainCalls++
|
||||
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 {
|
||||
return passedStream(`{"passed":false,"reason":"来源未标注","nextAction":"补上出处"}`)(ctx, model, msgs, tools, opts)
|
||||
},
|
||||
}
|
||||
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if mainCalls != 4 {
|
||||
t.Fatalf("main calls = %d, want 4(3 次续跑 + 收尾)", mainCalls)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 理由经 CheckPart 进过程区:3 次续跑各记一次,达上限时再记一次(与旧行为一致)。
|
||||
notes := 0
|
||||
for _, p := range rc.assistant {
|
||||
if p.Type == core.PartCheck && p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "来源未标注") {
|
||||
notes++
|
||||
}
|
||||
}
|
||||
if notes != 4 {
|
||||
t.Fatalf("verify notes = %d, want 4(3 次续跑 + 1 次封顶); checks = %+v", notes, rc.assistant)
|
||||
}
|
||||
// 前三个被降级的回复进了过程区;最后一次回复与收尾 Notice 留在正式区。
|
||||
demoted := 0
|
||||
for _, p := range texts {
|
||||
if p.Process && strings.HasPrefix(p.Text, "答案 v") {
|
||||
demoted++
|
||||
}
|
||||
}
|
||||
if demoted != 3 {
|
||||
t.Fatalf("demoted answers = %d, want 3; parts = %+v", demoted, texts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGateReasonTextParityAfterSink:下沉后门禁分支理由逐字一致——
|
||||
// verify 开启时,声称交付无工件的理由必须原样进入过程区。
|
||||
func TestGateReasonTextParityAfterSink(t *testing.T) {
|
||||
turns := [][]agentllm.Event{
|
||||
textDeltaTurn("文档已生成:甲.docx"),
|
||||
textDeltaTurn("文档已保存:乙.docx"),
|
||||
textDeltaTurn("文档已导出:丙.docx"),
|
||||
}
|
||||
r := &LLMRunner{Stream: scriptStream(turns, nil)}
|
||||
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
|
||||
if err := r.Execute(rc); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if !hasProcessNote(rc.assistant, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件") {
|
||||
t.Fatalf("gate reason missing after sink: %+v", textParts(rc.assistant))
|
||||
}
|
||||
texts := textParts(rc.assistant)
|
||||
last := texts[len(texts)-1]
|
||||
if last.Process || !strings.Contains(last.Text, "本轮未实际生成文件") {
|
||||
t.Fatalf("final notice = %+v", last)
|
||||
}
|
||||
// 伪造调用标记的分支理由也要逐字一致。
|
||||
r2 := &LLMRunner{Stream: scriptStream([][]agentllm.Event{
|
||||
textDeltaTurn("结果如下 [调用工具 write_file] 已完成"),
|
||||
textDeltaTurn("结果如下 [调用工具 write_file] 已完成"),
|
||||
textDeltaTurn("结果如下 [调用工具 write_file] 已完成"),
|
||||
}, nil)}
|
||||
rc2 := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
|
||||
if err := r2.Execute(rc2); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if !hasProcessNote(rc2.assistant, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用") {
|
||||
t.Fatalf("forged marker reason missing after sink: %+v", textParts(rc2.assistant))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user