From 7f2c8b9ee165ae2237ca7d31034d8b0547193268 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 7 Oct 2026 06:14:29 +0000 Subject: [PATCH] Sink the final gate mechanics and add the contract backend adapter Why: N1 WP5/WP6 library side - the gate decision logic, artifact checks, verdict parsing and progress-reminder throttling move from the product into a gates package behind ports, and the native backend gains a contract form so conformance has a real implementation to drive. Evidence: go vet clean; go test ./... 27 packages ok, including the conformance suite (succeeded/descriptor/error-sideeffects) against the real backend. Impact: new runtime/gates package (ClaimGate, CheckArtifacts, ParseVerdict, ProgressReminder, shared caps); adapters/native.Backend gains Contract() returning an exec.ContractBackend wrapper - the legacy facade keeps its signature so squad consumption is untouched. Product texts (fix prompts, verifier prompts) stay injected by hosts. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP5/WP6 --- adapters/native/.agentx/transcript.jsonl | 26 +++ .../native/.agentx/transcript.jsonl.meta.json | 7 + adapters/native/.native-last-failure | 1 + adapters/native/TASK.md | 1 + adapters/native/contract.go | 89 +++++++++ adapters/native/contract_test.go | 67 +++++++ adapters/native/execution.log | 4 + adapters/native/output.md | 1 + runtime/gates/gates.go | 176 ++++++++++++++++++ runtime/gates/gates_test.go | 133 +++++++++++++ runtime/gates/progress.go | 78 ++++++++ 11 files changed, 583 insertions(+) create mode 100644 adapters/native/.agentx/transcript.jsonl create mode 100644 adapters/native/.agentx/transcript.jsonl.meta.json create mode 100644 adapters/native/.native-last-failure create mode 100644 adapters/native/TASK.md create mode 100644 adapters/native/contract.go create mode 100644 adapters/native/contract_test.go create mode 100644 adapters/native/execution.log create mode 100644 adapters/native/output.md create mode 100644 runtime/gates/gates.go create mode 100644 runtime/gates/gates_test.go create mode 100644 runtime/gates/progress.go diff --git a/adapters/native/.agentx/transcript.jsonl b/adapters/native/.agentx/transcript.jsonl new file mode 100644 index 0000000..1d49de0 --- /dev/null +++ b/adapters/native/.agentx/transcript.jsonl @@ -0,0 +1,26 @@ +{"id":"025357339a97c04d","timestamp":"2026-10-07T06:11:15.521547827Z","type":"message","message":{"role":"system","content":"## Environment\ndate: 2026-10-07(周三)\nmodel: fake\nworkspace: /home/weichen/projects/Agent-System-Dev-wt-feature-agent-stack-n1/deps/agent-native/adapters/native\ntool profile: workspace\n\nDeliverable files: output.md. Use workspace tools to write those files. Do not invent paths outside the workspace.\n\nTools available in this workspace:\n- read_file: Read a UTF-8 text file inside the workspace. Without offset/limit returns the whole file (truncated at 200 KiB); with offset/limit returns numbered lines from a 1-based offset.\n- write_file: Create or replace a UTF-8 text file inside the workspace.\n- edit_file: Replace one unique exact text span in a workspace file.\n- multi_edit: Apply several unique-span replacements to one workspace file atomically: either all edits land or the file is left untouched.\n- list_dir: List names in a workspace directory.\n- grep: Search workspace text files. Literal substring by default; regex=true switches to Go regexp, include filters file names (e.g. *.go), ignore_case folds case. Caps at 50 hits.\n- glob: Find workspace files matching a glob pattern (** crosses directories, * within one segment). Returns up to 500 relative paths."}} +{"id":"46a70655ee65ddc3","parentId":"025357339a97c04d","timestamp":"2026-10-07T06:11:15.524296265Z","type":"message","message":{"role":"user","content":"hi"}} +{"id":"ab242da6bd626cae","parentId":"46a70655ee65ddc3","timestamp":"2026-10-07T06:11:15.524748291Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}} +{"id":"f637d2872ab9f34b","parentId":"ab242da6bd626cae","timestamp":"2026-10-07T06:11:15.525362933Z","type":"message","message":{"role":"user"}} +{"id":"c1bada8c14ef7ec0","parentId":"f637d2872ab9f34b","timestamp":"2026-10-07T06:11:15.525828249Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}} +{"id":"a01a79f9d2d2c219","parentId":"c1bada8c14ef7ec0","timestamp":"2026-10-07T06:11:15.52644899Z","type":"message","message":{"role":"user","content":"hi"}} +{"id":"37ce369360ed5bc3","parentId":"a01a79f9d2d2c219","timestamp":"2026-10-07T06:11:22.666535507Z","type":"message","message":{"role":"user","content":"hi"}} +{"id":"64a2479c0d206bf1","parentId":"37ce369360ed5bc3","timestamp":"2026-10-07T06:11:22.668999313Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}} +{"id":"61593f441df28345","parentId":"64a2479c0d206bf1","timestamp":"2026-10-07T06:11:22.669756111Z","type":"message","message":{"role":"user"}} +{"id":"c5aa1aebdaa45000","parentId":"61593f441df28345","timestamp":"2026-10-07T06:11:22.670242386Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}} +{"id":"3a9f8790c99f9e94","parentId":"c5aa1aebdaa45000","timestamp":"2026-10-07T06:11:22.670926846Z","type":"message","message":{"role":"user","content":"hi"}} +{"id":"064604f3b2f1ad65","parentId":"3a9f8790c99f9e94","timestamp":"2026-10-07T06:13:48.448571493Z","type":"message","message":{"role":"user","content":"hi"}} +{"id":"9d3b5edce163fdbe","parentId":"064604f3b2f1ad65","timestamp":"2026-10-07T06:13:48.449399929Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}} +{"id":"f280fb470f6e90f5","parentId":"9d3b5edce163fdbe","timestamp":"2026-10-07T06:13:48.450112717Z","type":"message","message":{"role":"user"}} +{"id":"258b37ad6c79c52d","parentId":"f280fb470f6e90f5","timestamp":"2026-10-07T06:13:48.450611112Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}} +{"id":"b6462f3dbbd35ce9","parentId":"258b37ad6c79c52d","timestamp":"2026-10-07T06:13:48.451303652Z","type":"message","message":{"role":"user","content":"hi"}} +{"id":"1f742b0f9b7c92a1","parentId":"b6462f3dbbd35ce9","timestamp":"2026-10-07T06:13:48.971444939Z","type":"message","message":{"role":"user","content":"hi"}} +{"id":"f472774cc9cb019d","parentId":"1f742b0f9b7c92a1","timestamp":"2026-10-07T06:13:48.973639624Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}} +{"id":"27e484d323f079d3","parentId":"f472774cc9cb019d","timestamp":"2026-10-07T06:13:48.97478444Z","type":"message","message":{"role":"user"}} +{"id":"9a97876386e882d4","parentId":"27e484d323f079d3","timestamp":"2026-10-07T06:13:48.97545809Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}} +{"id":"a8a4c88afce69517","parentId":"9a97876386e882d4","timestamp":"2026-10-07T06:13:48.976690973Z","type":"message","message":{"role":"user","content":"hi"}} +{"id":"c2865055fe240f43","parentId":"a8a4c88afce69517","timestamp":"2026-10-07T06:14:02.592947456Z","type":"message","message":{"role":"user","content":"hi"}} +{"id":"a02f22b1760c8363","parentId":"c2865055fe240f43","timestamp":"2026-10-07T06:14:02.595218749Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}} +{"id":"c3321f8e2d467eda","parentId":"a02f22b1760c8363","timestamp":"2026-10-07T06:14:02.59619084Z","type":"message","message":{"role":"user"}} +{"id":"7d0eb0b5d39283b2","parentId":"c3321f8e2d467eda","timestamp":"2026-10-07T06:14:02.596897779Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}} +{"id":"f99d01430932d356","parentId":"7d0eb0b5d39283b2","timestamp":"2026-10-07T06:14:02.598248098Z","type":"message","message":{"role":"user","content":"hi"}} diff --git a/adapters/native/.agentx/transcript.jsonl.meta.json b/adapters/native/.agentx/transcript.jsonl.meta.json new file mode 100644 index 0000000..4234922 --- /dev/null +++ b/adapters/native/.agentx/transcript.jsonl.meta.json @@ -0,0 +1,7 @@ +{ + "exec_id": "cx", + "leaf": "f99d01430932d356", + "model": "fake", + "scope": "", + "updated": "2026-10-07T06:14:02Z" +} \ No newline at end of file diff --git a/adapters/native/.native-last-failure b/adapters/native/.native-last-failure new file mode 100644 index 0000000..a2cccd3 --- /dev/null +++ b/adapters/native/.native-last-failure @@ -0,0 +1 @@ +stream ended without a message \ No newline at end of file diff --git a/adapters/native/TASK.md b/adapters/native/TASK.md new file mode 100644 index 0000000..32f95c0 --- /dev/null +++ b/adapters/native/TASK.md @@ -0,0 +1 @@ +hi \ No newline at end of file diff --git a/adapters/native/contract.go b/adapters/native/contract.go new file mode 100644 index 0000000..020b927 --- /dev/null +++ b/adapters/native/contract.go @@ -0,0 +1,89 @@ +package native + +import ( + "context" + "errors" + "strings" + + "agent-native/exec" +) + +// contractBackend 把 Backend 适配为 exec.ContractBackend。 +// 独立类型的原因:旧门面 Execute(ctx, exec.Request) 与契约的 +// Execute(ctx, exec.ContractRequest) 签名冲突,同名方法不能并存; +// 包装让现有 squad 消费(旧门面)与契约消费(conformance/Core)互不影响。 +type contractBackend struct{ b *Backend } + +// Contract 返回该后端的契约形态(N1 WP6;旧门面保持不变)。 +func (b *Backend) Contract() exec.ContractBackend { + return contractBackend{b: b} +} + +func (c contractBackend) Descriptor() exec.BackendDescriptor { + return exec.BackendDescriptor{ + Name: c.b.Name(), + Capabilities: []exec.Capability{exec.CapText, exec.CapStream, exec.CapTools}, + } +} + +func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest) (*exec.ContractResult, error) { + var onEvent func(ev exec.ProcessEvent) + if req.OnEvent != nil { + next := req.OnEvent + onEvent = func(ev exec.ProcessEvent) { + next(exec.Event{Kind: exec.EventKind(ev.Type), Text: ev.Text, Tool: ev.Tool, Detail: ev.Detail}) + } + } + // 契约语义:空 ToolPolicy.Profile 等同 reasoning(无工具纯文本任务)。 + profile := strings.TrimSpace(req.ToolPolicy.Profile) + if profile == "" { + profile = exec.ToolProfileReasoning + } + old := exec.Request{ + Prompt: req.Prompt, + Instructions: req.Instructions, + ExecutionID: req.ExecutionID, + Targets: append([]string(nil), req.Targets...), + ToolProfile: profile, + ToolAllowlist: append([]string(nil), req.ToolPolicy.Allowlist...), + WorkDir: req.Workspace.Root, + OnEvent: onEvent, + } + if b := req.ContractBudget; b.MaxPromptTokens > 0 || b.MaxCompletionTokens > 0 || !b.Deadline.IsZero() { + old.Budget = &exec.Budget{ + MaxPromptTokens: b.MaxPromptTokens, + MaxCompletionTokens: b.MaxCompletionTokens, + Deadline: b.Deadline, + } + } + res, err := c.b.Execute(ctx, old) + if err != nil { + // 传输/实现级错误:按契约映射为 Unknown,不经 Go error 返回业务终态。 + return &exec.ContractResult{ + Outcome: exec.OutcomeUnknown, + Err: &exec.Error{Code: exec.ErrProviderUnavailable, Reason: err.Error(), SideEffects: exec.SideEffectPossible}, + }, nil + } + out := &exec.ContractResult{ + Logs: res.Logs, + Usage: exec.Usage{ + PromptTokens: res.PromptTokens, + CompletionTokens: res.CompletionTokens, + }, + } + switch { + case res.Success: + out.Outcome = exec.OutcomeSucceeded + out.Files = res.Patches + case errors.Is(ctx.Err(), context.Canceled): + out.Outcome = exec.OutcomeCanceled + out.Err = &exec.Error{Code: exec.ErrCanceled, Reason: res.ErrorReason, SideEffects: exec.SideEffectPossible} + case errors.Is(ctx.Err(), context.DeadlineExceeded): + out.Outcome = exec.OutcomeFailed + out.Err = &exec.Error{Code: exec.ErrDeadlineExceeded, Reason: res.ErrorReason, SideEffects: exec.SideEffectPossible} + default: + out.Outcome = exec.OutcomeFailed + out.Err = &exec.Error{Code: exec.ErrProviderRejected, Reason: res.ErrorReason, Retryable: res.Retryable, SideEffects: exec.SideEffectPossible} + } + return out, nil +} diff --git a/adapters/native/contract_test.go b/adapters/native/contract_test.go new file mode 100644 index 0000000..11c87d0 --- /dev/null +++ b/adapters/native/contract_test.go @@ -0,0 +1,67 @@ +package native + +import ( + "context" + "testing" + "time" + + "agent-native/conformance" + "agent-native/exec" + "agent-native/runtime/llm" +) + +// TestContractBackendConformance 把 native 后端的契约形态挂上最小契约套件 +// (N1 WP6:conformance 首次获得真实实现调用方)。 +func TestContractBackendConformance(t *testing.T) { + conformance.Suite(t, func() exec.ContractBackend { + return New(Options{ + ModelOptions: ModelOptions{ + Model: "fake", + Stream: script( + llm.Message{Role: llm.RoleAssistant, Content: "hi", StopReason: llm.StopEnd}, + ), + }, + }).Contract() + }) +} + +// TestContractExecuteCancel 取消路径:流阻塞到 ctx 结束,Outcome=Canceled 且 +// SideEffects 显式。 +func TestContractExecuteCancel(t *testing.T) { + b := New(Options{ + ModelOptions: ModelOptions{ + Model: "fake", + Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + ch := make(chan llm.Event) + go func() { + <-ctx.Done() + close(ch) + }() + return ch + }, + }, + }).Contract() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + res, err := b.Execute(ctx, exec.ContractRequest{ExecutionID: "cx", Prompt: "hi"}) + if err != nil { + t.Fatalf("transport error: %v", err) + } + if res.Outcome != exec.OutcomeCanceled && res.Outcome != exec.OutcomeFailed { + t.Fatalf("outcome = %q", res.Outcome) + } + if res.Err == nil || res.Err.SideEffects == "" { + t.Fatalf("err = %+v", res.Err) + } +} + +// TestContractDescriptor 描述的身份与能力。 +func TestContractDescriptor(t *testing.T) { + d := New(Options{}).Contract().Descriptor() + if d.Name != "agent-native" { + t.Fatalf("name = %q", d.Name) + } + if !d.Has(exec.CapText) || !d.Has(exec.CapStream) || !d.Has(exec.CapTools) { + t.Fatalf("capabilities = %v", d.Capabilities) + } +} diff --git a/adapters/native/execution.log b/adapters/native/execution.log new file mode 100644 index 0000000..dbdb8dc --- /dev/null +++ b/adapters/native/execution.log @@ -0,0 +1,4 @@ +execution_id=cx session= +agent_start +turn_start +message_start diff --git a/adapters/native/output.md b/adapters/native/output.md new file mode 100644 index 0000000..32f95c0 --- /dev/null +++ b/adapters/native/output.md @@ -0,0 +1 @@ +hi \ No newline at end of file diff --git a/runtime/gates/gates.go b/runtime/gates/gates.go new file mode 100644 index 0000000..0049f63 --- /dev/null +++ b/runtime/gates/gates.go @@ -0,0 +1,176 @@ +// Package gates 提供终轮门禁与进度提醒的组合机制(自产品下沉)。 +// 产品依赖——工件存取、质量评分、验证器模型、提示词、进度快照——全部端口化; +// 判定逻辑(正则族)、续跑上限与节流策略由本包持有。 +// 端口为 nil 时语义与「未启用该检查」一致,不改变既有行为。 +package gates + +import ( + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "strings" +) + +// 续跑与节流上限。与产品现网取值一致;消费方引用本包常量,不再各自硬编码。 +const ( + // MaxDeliverableGuards 是「声称交付但无工件」的纠正轮次上限。 + MaxDeliverableGuards = 2 + // MaxVerifyContinuations 是终轮检查的续跑上限(与内核 Stop 的 3 次上限对齐)。 + MaxVerifyContinuations = 3 + // ReminderToolGap 是连续这么多个工具轮没有更新计划或待办就提醒一次。 + ReminderToolGap = 3 + // MaxChecklistReminders 是每个 run 的进度提醒次数上限。 + MaxChecklistReminders = 3 +) + +// ArtifactLookup 按 ID 取工件:名称、内容与是否存在。 +type ArtifactLookup func(id string) (name string, content []byte, ok bool) + +// QualityScorer 对文本类工件评分;nil 表示跳过评分(视为通过)。 +// why 在未通过时给出可读原因。 +type QualityScorer func(name string, content []byte) (passed bool, why string) + +// ArtifactRef 是一次工件引用(库内最小形态;宿主负责投影自有类型)。 +type ArtifactRef struct { + ID string + Name string + Kind string +} + +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(`\[(?:调用工具|工具结果|产出工件) `) +) + +// ClaimGate 判断终轮正文是否在没有对应工件的情况下声称交付。 +// 引用本轮已产出、或确实存在的工件不算谎称;伪造调用标记始终拦截。 +// lookup 为 nil 时,任何引用未产出工件的 art_ 编号都视为不存在(拦截)。 +func ClaimGate(body string, produced []ArtifactRef, lookup ArtifactLookup) (bool, string) { + if strings.TrimSpace(body) == "" { + return false, "" + } + if forgedCallRe.MatchString(body) { + return true, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用" + } + producedIDs := map[string]bool{} + for _, ref := range produced { + if ref.ID != "" { + producedIDs[ref.ID] = true + } + } + knownExisting := false + for _, id := range artIDRe.FindAllString(body, -1) { + if producedIDs[id] { + continue + } + if lookup != nil { + if _, _, ok := lookup(id); ok { + knownExisting = true + continue + } + } + return true, "回复引用了不存在的工件" + } + if len(producedIDs) > 0 || knownExisting { + return false, "" + } + if fileClaimRe.MatchString(body) || softClaimRe.MatchString(body) { + return true, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件" + } + return false, "" +} + +// CheckArtifacts 确认登记的工件存在且非空。 +// md、txt、docx 还要过 scorer;其他类型只检查存在与非空。 +func CheckArtifacts(refs []ArtifactRef, lookup ArtifactLookup, scorer QualityScorer) []string { + var reasons []string + for _, ref := range refs { + if ref.ID == "" { + reasons = append(reasons, "工件引用缺少 ID") + continue + } + name := ref.Name + if name == "" { + name = ref.ID + } + var ( + content []byte + ok bool + artName string + ) + if lookup != nil { + artName, content, ok = lookup(ref.ID) + } + if !ok { + reasons = append(reasons, name+" 不存在") + continue + } + if artName != "" { + name = artName + } + if len(content) == 0 { + reasons = append(reasons, name+" 内容为空") + continue + } + switch strings.ToLower(filepath.Ext(name)) { + case ".md", ".txt", ".docx": + if scorer == nil { + continue + } + passed, why := scorer(name, content) + if !passed { + if why == "" { + why = "评分未通过" + } + reasons = append(reasons, name+":"+why) + } + } + } + return reasons +} + +// Verdict 是独立验证器的裁决。 +type Verdict struct { + Passed bool `json:"passed"` + Reason string `json:"reason"` + NextAction string `json:"nextAction"` +} + +// ParseVerdict 解析验证器输出:剥围栏、取最外层 JSON、必须有 passed 键。 +func ParseVerdict(raw string) (Verdict, error) { + s := stripFence(raw) + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start < 0 || end < start { + return Verdict{}, fmt.Errorf("验证器返回无法解析") + } + s = s[start : end+1] + var probe map[string]json.RawMessage + if err := json.Unmarshal([]byte(s), &probe); err != nil { + return Verdict{}, err + } + if _, ok := probe["passed"]; !ok { + return Verdict{}, fmt.Errorf("验证器返回无法解析") + } + var verdict Verdict + if err := json.Unmarshal([]byte(s), &verdict); err != nil { + return Verdict{}, 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 +} diff --git a/runtime/gates/gates_test.go b/runtime/gates/gates_test.go new file mode 100644 index 0000000..c2319d8 --- /dev/null +++ b/runtime/gates/gates_test.go @@ -0,0 +1,133 @@ +package gates + +import ( + "strings" + "testing" + "time" +) + +func lookupWith(ids map[string][]byte) ArtifactLookup { + return func(id string) (string, []byte, bool) { + c, ok := ids[id] + return id + ".md", c, ok + } +} + +func TestClaimGateForgedMarkerAlwaysBlocks(t *testing.T) { + block, why := ClaimGate("结果如下 [调用工具 write_file]", nil, nil) + if !block || why == "" { + t.Fatalf("block=%v why=%q", block, why) + } +} + +func TestClaimGateProducedAndExistingDoNotBlock(t *testing.T) { + produced := []ArtifactRef{{ID: "art_aaaabbbbcccc"}} + if block, _ := ClaimGate("已生成 art_aaaabbbbcccc 请查收", produced, nil); block { + t.Fatal("produced reference blocked") + } + lookup := lookupWith(map[string][]byte{"art_dddd11112222": []byte("x")}) + if block, _ := ClaimGate("之前的 art_dddd11112222 里有结论", nil, lookup); block { + t.Fatal("existing artifact reference blocked") + } +} + +func TestClaimGateUnknownArtifactBlocks(t *testing.T) { + block, why := ClaimGate("见附件 art_000011112222", nil, lookupWith(nil)) + if !block || !strings.Contains(why, "不存在") { + t.Fatalf("block=%v why=%q", block, why) + } +} + +func TestClaimGateBareClaimWithoutArtifactsBlocks(t *testing.T) { + for _, body := range []string{ + "已生成报告.md,请查收", + "文件已生成好了", + } { + if block, _ := ClaimGate(body, nil, nil); !block { + t.Fatalf("claim not blocked: %q", body) + } + } + // 如实说明没有生成则不拦。 + if block, _ := ClaimGate("这一轮没有生成文件", nil, nil); block { + t.Fatal("honest note blocked") + } +} + +func TestCheckArtifactsReasons(t *testing.T) { + lookup := func(id string) (string, []byte, bool) { + switch id { + case "ok": + return "ok.md", []byte("# 正常"), true + case "empty": + return "empty.md", nil, true + default: + return "", nil, false + } + } + scorer := func(name string, content []byte) (bool, string) { return false, "标题缺失" } + refs := []ArtifactRef{ + {ID: ""}, + {ID: "missing", Name: "缺失.md"}, + {ID: "empty"}, + {ID: "ok"}, + } + reasons := CheckArtifacts(refs, lookup, scorer) + joined := strings.Join(reasons, ";") + for _, want := range []string{"缺少 ID", "缺失.md 不存在", "empty.md 内容为空", "ok.md:标题缺失"} { + if !strings.Contains(joined, want) { + t.Fatalf("reasons %q 缺 %q", joined, want) + } + } +} + +func TestCheckArtifactsNilScorerSkipsQuality(t *testing.T) { + lookup := lookupWith(map[string][]byte{"a": []byte("x")}) + if reasons := CheckArtifacts([]ArtifactRef{{ID: "a"}}, lookup, nil); len(reasons) != 0 { + t.Fatalf("reasons = %v", reasons) + } +} + +func TestParseVerdictForms(t *testing.T) { + v, err := ParseVerdict("```json\n{\"passed\":true,\"reason\":\"ok\"}\n```") + if err != nil || !v.Passed { + t.Fatalf("v=%+v err=%v", v, err) + } + if _, err := ParseVerdict("没有 JSON"); err == nil { + t.Fatal("garbage accepted") + } + if _, err := ParseVerdict("{\"reason\":\"no passed key\"}"); err == nil { + t.Fatal("missing passed accepted") + } +} + +func TestProgressReminderThrottleAndFormat(t *testing.T) { + r := &ProgressReminder{} + now := time.Now() + first := r.Remind([]string{"写第三章"}, []string{"评审"}, now) + if len(first) != 1 { + t.Fatalf("first = %v", first) + } + text := first[0] + for _, want := range []string{"已连续 3 个工具轮", "- 待办:写第三章", "- 计划:评审", "请更新计划或待办"} { + if !strings.Contains(text, want) { + t.Fatalf("text 缺 %q:\n%s", want, text) + } + } + // 内容相同且计数不同:key 不同,应继续发出直到封顶。 + if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); len(got) != 1 { + t.Fatalf("second = %v", got) + } + if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); len(got) != 1 { + t.Fatalf("third = %v", got) + } + if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); got != nil { + t.Fatalf("fourth should be capped: %v", got) + } + if r.Sent() != 3 { + t.Fatalf("sent = %d", r.Sent()) + } + // 无内容不发。 + if got := (&ProgressReminder{}).Remind(nil, nil, now); got != nil { + t.Fatalf("empty = %v", got) + } +} diff --git a/runtime/gates/progress.go b/runtime/gates/progress.go new file mode 100644 index 0000000..50c4e48 --- /dev/null +++ b/runtime/gates/progress.go @@ -0,0 +1,78 @@ +package gates + +import ( + "fmt" + "strings" + "time" + + "agent-native/runtime/reminders" +) + +// ProgressReminder 是进度提醒的节流状态机与格式化机制。 +// 计数(第几个工具轮)由调用方按自己的事件流维护;本机制负责 +// 「达到阈值后的去重、封顶与文案」。 +type ProgressReminder struct { + Gap int // 连续多少个工具轮未更新即提醒;0 = ReminderToolGap + Cap int // 每 run 提醒上限;0 = MaxChecklistReminders + notes *reminders.Bus + sent int +} + +func (r *ProgressReminder) gap() int { + if r.Gap <= 0 { + return ReminderToolGap + } + return r.Gap +} + +func (r *ProgressReminder) cap() int { + if r.Cap <= 0 { + return MaxChecklistReminders + } + return r.Cap +} + +// Sent 报告已发出的提醒条数(调用方判定封顶用)。 +func (r *ProgressReminder) Sent() int { return r.sent } + +// Remind 在给定未完成项时发一条提醒;节流或没有内容时返回 nil。 +// 文案与产品现网一致:「计划和待办已连续 N 个工具轮没有更新」。 +func (r *ProgressReminder) Remind(todos, plans []string, now time.Time) []string { + if r.sent >= r.cap() { + return nil + } + text := FormatUnfinished(r.gap(), todos, plans) + if text == "" { + return nil + } + if r.notes == nil { + r.notes = reminders.New(0, r.cap()) + } + key := fmt.Sprintf("%d:%s", r.sent, text) + if !r.notes.Admit(reminders.Note{Kind: reminders.KindTodo, Key: key, Text: text}, now) { + return nil + } + r.sent++ + return []string{text} +} + +// FormatUnfinished 渲染未完成项提醒;无内容时返回空串。 +func FormatUnfinished(gap int, todos, plans []string) string { + var lines []string + for _, item := range todos { + if text := strings.TrimSpace(item); text != "" { + lines = append(lines, "- 待办:"+text) + } + } + for _, step := range plans { + if text := strings.TrimSpace(step); text != "" { + lines = append(lines, "- 计划:"+text) + } + } + if len(lines) == 0 { + return "" + } + return fmt.Sprintf("计划和待办已连续 %d 个工具轮没有更新。未完成项:\n", gap) + + strings.Join(lines, "\n") + + "\n请更新计划或待办,或在回复里说明为什么先不做。" +}