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
This commit is contained in:
@@ -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"}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"exec_id": "cx",
|
||||
"leaf": "f99d01430932d356",
|
||||
"model": "fake",
|
||||
"scope": "",
|
||||
"updated": "2026-10-07T06:14:02Z"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
stream ended without a message
|
||||
@@ -0,0 +1 @@
|
||||
hi
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
execution_id=cx session=
|
||||
agent_start
|
||||
turn_start
|
||||
message_start
|
||||
@@ -0,0 +1 @@
|
||||
hi
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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请更新计划或待办,或在回复里说明为什么先不做。"
|
||||
}
|
||||
Reference in New Issue
Block a user