Sink the final gate mechanics and add the contract backend adapter (N1 WP5/WP6) #19

Merged
weichen merged 1 commits from feature/agent-stack-n1 into main 2026-10-07 06:21:01 +00:00
11 changed files with 583 additions and 0 deletions
+26
View File
@@ -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"
}
+1
View File
@@ -0,0 +1 @@
stream ended without a message
+1
View File
@@ -0,0 +1 @@
hi
+89
View File
@@ -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
}
+67
View File
@@ -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)
}
}
+4
View File
@@ -0,0 +1,4 @@
execution_id=cx session=
agent_start
turn_start
message_start
+1
View File
@@ -0,0 +1 @@
hi
+176
View File
@@ -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
}
+133
View File
@@ -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)
}
}
+78
View File
@@ -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请更新计划或待办,或在回复里说明为什么先不做。"
}