From 6fd1d441a95423e27be6f6159207d5d0821bec4d Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 3 Oct 2026 16:19:36 +0000 Subject: [PATCH] Keep tool pairs and fold reminders into the last result Why: Old tool results were filling the window, compaction could split a tool group or commit a stale summary, and reminders arrived as extra user messages. Evidence: Compaction retries a conflict and opens the circuit after three failures; clearing keeps the last five tool results; a stale file write is rejected; RPC reports started, queued, or handled. Impact: Requests over the context window replace older tool results with a placeholder. Rewriting an existing file now requires a read in the same run. Subagent text over 32000 characters is capped. Refs: feature/context-engineering Co-authored-by: Cursor --- adapters/native/backend.go | 3 +- rpc/server.go | 28 ++++++-- rpc/server_test.go | 6 +- runtime/filestate/filestate.go | 63 +++++++++++++++++ runtime/filestate/filestate_test.go | 40 +++++++++++ runtime/loop.go | 58 +++++++++++----- runtime/reminders/reminders.go | 97 ++++++++++++++++++++++++++ runtime/reminders/reminders_test.go | 53 ++++++++++++++ runtime/session/compact.go | 34 +++++++-- runtime/session/microcompact.go | 70 +++++++++++++++++++ runtime/session/microcompact_test.go | 60 ++++++++++++++++ runtime/session/r2_regression_test.go | 99 +++++++++++++++++++++++++++ runtime/session_agent.go | 7 ++ runtime/subagent/subagent.go | 25 ++++++- runtime/subagent/subagent_test.go | 23 +++++++ runtime/tools/builtin.go | 33 +++++++-- runtime/tools/filestate_test.go | 38 ++++++++++ runtime/tools/registry.go | 25 +++++-- runtime/tools/select.go | 66 ++++++++++++++++++ runtime/tools/select_test.go | 24 +++++++ 20 files changed, 810 insertions(+), 42 deletions(-) create mode 100644 runtime/filestate/filestate.go create mode 100644 runtime/filestate/filestate_test.go create mode 100644 runtime/reminders/reminders.go create mode 100644 runtime/reminders/reminders_test.go create mode 100644 runtime/session/microcompact.go create mode 100644 runtime/session/microcompact_test.go create mode 100644 runtime/tools/filestate_test.go create mode 100644 runtime/tools/select.go create mode 100644 runtime/tools/select_test.go diff --git a/adapters/native/backend.go b/adapters/native/backend.go index 1ef2715..20c0487 100644 --- a/adapters/native/backend.go +++ b/adapters/native/backend.go @@ -14,6 +14,7 @@ import ( "agent-native/exec" "agent-native/runtime" + "agent-native/runtime/filestate" "agent-native/runtime/llm" "agent-native/runtime/mcp" "agent-native/runtime/promptx" @@ -171,7 +172,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, var reg *tools.Registry if profile == exec.ToolProfileWorkspace { - reg = tools.WorkspaceTools(workDir) + reg = tools.WorkspaceToolsTracked(workDir, filestate.New()) skills.RegisterLoader(reg, skillStore) if b.opts.Search != nil { tools.RegisterSearch(reg, b.opts.Search) diff --git a/rpc/server.go b/rpc/server.go index be097f7..08235dd 100644 --- a/rpc/server.go +++ b/rpc/server.go @@ -31,6 +31,8 @@ type Response struct { Text string `json:"text,omitempty"` Error string `json:"error,omitempty"` Result any `json:"result,omitempty"` + // Status 是命令回执:started 已接手,queued 先排队,handled 已处理完。 + Status string `json:"status,omitempty"` } // Server serves RPC commands against one agent session and its transcript. @@ -60,20 +62,31 @@ func (s *Server) dispatch(ctx context.Context, req Request) Response { if s.Agent == nil { return fail("no agent") } - res, err := s.Agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Message}) + msg := llm.Message{Role: llm.RoleUser, Content: req.Message} + if s.Agent.Busy() { + s.Agent.FollowUp(msg) + return Response{OK: true, Status: "queued"} + } + res, err := s.Agent.Prompt(ctx, msg) if err != nil { return fail(err.Error()) } if res.Err != nil { return fail(res.Err.Error()) } - return Response{OK: true, Text: res.Text} + return Response{OK: true, Text: res.Text, Status: "handled"} case "steer": + if s.Agent == nil { + return fail("no agent") + } s.Agent.Steer(llm.Message{Role: llm.RoleUser, Content: req.Message}) - return Response{OK: true} + return Response{OK: true, Status: queueStatus(s.Agent.Busy())} case "follow_up": + if s.Agent == nil { + return fail("no agent") + } s.Agent.FollowUp(llm.Message{Role: llm.RoleUser, Content: req.Message}) - return Response{OK: true} + return Response{OK: true, Status: queueStatus(s.Agent.Busy())} case "abort": s.Agent.Abort() return Response{OK: true} @@ -184,6 +197,13 @@ func (s *Server) dispatch(ctx context.Context, req Request) Response { func fail(msg string) Response { return Response{OK: false, Error: msg} } +func queueStatus(busy bool) string { + if busy { + return "queued" + } + return "started" +} + func encode(resp Response) ([]byte, error) { raw, err := json.Marshal(resp) if err != nil { diff --git a/rpc/server_test.go b/rpc/server_test.go index f332292..a6fb7ab 100644 --- a/rpc/server_test.go +++ b/rpc/server_test.go @@ -59,9 +59,13 @@ func TestRPCSessionCommandsAndClient(t *testing.T) { t.Fatalf("fork %+v %v", resp, err) } resp, err = client.Call(context.Background(), Request{Type: "prompt", Message: "hi"}) - if err != nil || !resp.OK || resp.Text != "echo" { + if err != nil || !resp.OK || resp.Text != "echo" || resp.Status != "handled" { t.Fatalf("prompt %+v %v", resp, err) } + resp, err = client.Call(context.Background(), Request{Type: "steer", Message: "later"}) + if err != nil || !resp.OK || resp.Status != "started" { + t.Fatalf("steer %+v %v", resp, err) + } htmlPath := filepath.Join(dir, "out.html") resp, err = client.Call(context.Background(), Request{Type: "export_html", Path: htmlPath}) if err != nil || !resp.OK { diff --git a/runtime/filestate/filestate.go b/runtime/filestate/filestate.go new file mode 100644 index 0000000..143c0f7 --- /dev/null +++ b/runtime/filestate/filestate.go @@ -0,0 +1,63 @@ +// Package filestate 记录本轮读过的文件,并在改写前核对修改时间与大小。 +package filestate + +import ( + "fmt" + "os" + "sync" + "time" +) + +type stamp struct { + mod time.Time + size int64 +} + +// Tracker 是一次 run 内的读取记录。nil 接收者表示不检查。 +type Tracker struct { + mu sync.Mutex + seen map[string]stamp +} + +func New() *Tracker { + return &Tracker{seen: map[string]stamp{}} +} + +// NoteRead 记下当前的修改时间与大小。 +func (t *Tracker) NoteRead(path string) error { + if t == nil { + return nil + } + info, err := os.Stat(path) + if err != nil { + return err + } + t.mu.Lock() + t.seen[path] = stamp{mod: info.ModTime(), size: info.Size()} + t.mu.Unlock() + return nil +} + +// CheckWrite 允许新建。已存在的文件必须在本轮读过,且修改时间与大小未变。 +func (t *Tracker) CheckWrite(path string) error { + if t == nil { + return nil + } + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + t.mu.Lock() + got, ok := t.seen[path] + t.mu.Unlock() + if !ok { + return fmt.Errorf("请先读取该文件再修改:%s", path) + } + if !got.mod.Equal(info.ModTime()) || got.size != info.Size() { + return fmt.Errorf("文件在读取后已变化,请重新读取:%s", path) + } + return nil +} diff --git a/runtime/filestate/filestate_test.go b/runtime/filestate/filestate_test.go new file mode 100644 index 0000000..8a271c7 --- /dev/null +++ b/runtime/filestate/filestate_test.go @@ -0,0 +1,40 @@ +package filestate + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestCheckWriteRejectsStaleFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "note.txt") + if err := os.WriteFile(path, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + tr := New() + if err := tr.CheckWrite(path); err == nil || !strings.Contains(err.Error(), "请先读取") { + t.Fatalf("unread write: %v", err) + } + if err := tr.NoteRead(path); err != nil { + t.Fatal(err) + } + if err := tr.CheckWrite(path); err != nil { + t.Fatal(err) + } + // 改大小,并尽量推开修改时间。 + time.Sleep(20 * time.Millisecond) + if err := os.WriteFile(path, []byte("v1-changed"), 0o644); err != nil { + t.Fatal(err) + } + err := tr.CheckWrite(path) + if err == nil || !strings.Contains(err.Error(), "请重新读取") { + t.Fatalf("stale write: %v", err) + } + missing := filepath.Join(dir, "new.txt") + if err := tr.CheckWrite(missing); err != nil { + t.Fatalf("create: %v", err) + } +} diff --git a/runtime/loop.go b/runtime/loop.go index 74f9530..6809ea7 100644 --- a/runtime/loop.go +++ b/runtime/loop.go @@ -9,6 +9,7 @@ import ( "time" "agent-native/runtime/llm" + "agent-native/runtime/reminders" "agent-native/runtime/session" "agent-native/runtime/tools" @@ -83,7 +84,7 @@ type Config struct { // 同一次 Run 内与 OnEvent、BeforeTool 串行,不会并发调用。 AfterTool func(call llm.ToolCall, res llm.Message) // Reminders 在每次调用模型前提供提醒。正文包在 system-reminder 里, - // 只进入本次请求,不写进 Session。 + // 并入最后一条工具结果或用户消息,只进入本次请求,不写进 Session。 Reminders func(turn int) []string // RepeatGuard 为真时,同名且参数按键排序后相同的调用连续 3 次,注入一条提醒。 // 每个 run 最多 3 条,不中断循环。 @@ -94,6 +95,15 @@ type Config struct { // MaxLengthContinuations 是 length 续写的次数上限。 // 0 表示默认 3 次;负数表示不限制。 MaxLengthContinuations int + // ContextWindow 是模型窗口的字符估算。0 表示用保守默认值。 + // 超过窗口减去 OutputReserve 与 ContextBuffer 时,清理最近若干条之前的旧工具结果。 + ContextWindow int + // OutputReserve 是留给模型输出的字符数。0 表示默认预留。 + OutputReserve int + // ContextBuffer 是窗口估算的余量。0 表示默认余量。 + ContextBuffer int + // KeepToolResults 是清理时保留的最近工具结果条数。0 表示 5。 + KeepToolResults int } // StopInfo 是 Stop 钩子看到的本 run 进度。 @@ -182,7 +192,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { } emit(cfg, Event{Type: "turn_start"}) emit(cfg, Event{Type: "message_start"}) - request := withEphemeral(messages, turnReminders(cfg, turn, &repeats, &lengthNudge)) + request := prepareRequest(cfg, messages, turn, &repeats, &lengthNudge) end, errEv := consume(ctx, cfg, request) if errEv != nil { cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable) @@ -534,22 +544,42 @@ func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usag return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice} } -func reminderMessage(text string) llm.Message { - return llm.Message{Role: llm.RoleUser, Content: "\n" + text + "\n"} +func prepareRequest(cfg Config, messages []llm.Message, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message { + request := clearOldResults(cfg, messages) + return reminders.Attach(request, turnReminderTexts(cfg, turn, repeats, lengthNudge)) } -func turnReminders(cfg Config, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message { - var extra []llm.Message +func clearOldResults(cfg Config, messages []llm.Message) []llm.Message { + window := cfg.ContextWindow + if window <= 0 { + window = session.DefaultContextWindow + } + reserve := cfg.OutputReserve + if reserve <= 0 { + reserve = session.DefaultOutputReserve + } + buffer := cfg.ContextBuffer + if buffer <= 0 { + buffer = session.DefaultContextBuffer + } + if !session.NeedsClear(session.EstimateChars(messages), window, reserve, buffer) { + return messages + } + return session.ClearOldToolResults(messages, cfg.KeepToolResults) +} + +func turnReminderTexts(cfg Config, turn int, repeats *repeatState, lengthNudge *bool) []string { + var extra []string if cfg.Reminders != nil { for _, text := range cfg.Reminders(turn) { if strings.TrimSpace(text) == "" { continue } - extra = append(extra, reminderMessage(text)) + extra = append(extra, text) } } if lengthNudge != nil && *lengthNudge { - extra = append(extra, llm.Message{Role: llm.RoleUser, Content: lengthContinuePrompt}) + extra = append(extra, lengthContinuePrompt) *lengthNudge = false } if cfg.RepeatGuard && repeats != nil { @@ -559,23 +589,13 @@ func turnReminders(cfg Config, turn int, repeats *repeatState, lengthNudge *bool due = room } for i := 0; i < due; i++ { - extra = append(extra, reminderMessage(repeatReminderText)) + extra = append(extra, repeatReminderText) repeats.reminders++ } } return extra } -func withEphemeral(messages, extra []llm.Message) []llm.Message { - if len(extra) == 0 { - return messages - } - out := make([]llm.Message, 0, len(messages)+len(extra)) - out = append(out, messages...) - out = append(out, extra...) - return out -} - type repeatState struct { sig string streak int diff --git a/runtime/reminders/reminders.go b/runtime/reminders/reminders.go new file mode 100644 index 0000000..254d65d --- /dev/null +++ b/runtime/reminders/reminders.go @@ -0,0 +1,97 @@ +// Package reminders 收集一轮里要带给模型的提醒,并做节流。 +// 提醒并入最后一条工具结果或用户消息,不另起一条 user 消息。 +package reminders + +import ( + "strings" + "sync" + "time" + + "agent-native/runtime/llm" +) + +type Kind string + +const ( + KindTodo Kind = "todo" + KindPlan Kind = "plan" + KindBackground Kind = "background" + KindDeliverable Kind = "deliverable" + KindBudget Kind = "budget" +) + +// Note 是一条待投递的提醒。Key 相同的提醒在本轮只投递一次。 +type Note struct { + Kind Kind + Key string + Text string +} + +// Bus 按最小间隔、每 run 上限和去重决定是否投递。 +type Bus struct { + mu sync.Mutex + minGap time.Duration + cap int + sent int + last time.Time + seen map[string]struct{} +} + +func New(minGap time.Duration, cap int) *Bus { + if cap <= 0 { + cap = 3 + } + return &Bus{minGap: minGap, cap: cap, seen: map[string]struct{}{}} +} + +// Admit 在通过节流时返回真。同一 Kind+Key 只通过一次。 +func (b *Bus) Admit(n Note, now time.Time) bool { + if b == nil { + return false + } + b.mu.Lock() + defer b.mu.Unlock() + if b.sent >= b.cap { + return false + } + if b.minGap > 0 && !b.last.IsZero() && now.Sub(b.last) < b.minGap { + return false + } + key := string(n.Kind) + "\n" + n.Key + if _, ok := b.seen[key]; ok { + return false + } + b.seen[key] = struct{}{} + b.last = now + b.sent++ + return true +} + +// Attach 把提醒正文并入最后一条工具结果或用户消息。 +// 找不到这两种消息时原样返回,不追加 user 消息。 +func Attach(messages []llm.Message, texts []string) []llm.Message { + var lines []string + for _, text := range texts { + text = strings.TrimSpace(text) + if text != "" { + lines = append(lines, text) + } + } + if len(lines) == 0 || len(messages) == 0 { + return messages + } + block := "\n" + strings.Join(lines, "\n") + "\n" + out := append([]llm.Message{}, messages...) + for i := len(out) - 1; i >= 0; i-- { + if out[i].Role != llm.RoleTool && out[i].Role != llm.RoleUser { + continue + } + if out[i].Content != "" { + out[i].Content += "\n" + block + } else { + out[i].Content = block + } + return out + } + return messages +} diff --git a/runtime/reminders/reminders_test.go b/runtime/reminders/reminders_test.go new file mode 100644 index 0000000..d639d5c --- /dev/null +++ b/runtime/reminders/reminders_test.go @@ -0,0 +1,53 @@ +package reminders + +import ( + "strings" + "testing" + "time" + + "agent-native/runtime/llm" +) + +func TestAttachDoesNotAddUserAfterTool(t *testing.T) { + msgs := []llm.Message{ + {Role: llm.RoleUser, Content: "请改文件"}, + {Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file"}}}, + {Role: llm.RoleTool, Name: "read_file", ToolCallID: "c1", Content: "原文"}, + } + out := Attach(msgs, []string{"计划和待办已过期"}) + if len(out) != len(msgs) { + t.Fatalf("len %d, want %d", len(out), len(msgs)) + } + for i := range msgs { + if out[i].Role != msgs[i].Role { + t.Fatalf("role changed at %d: %s", i, out[i].Role) + } + } + last := out[len(out)-1] + if last.Role != llm.RoleTool || !strings.Contains(last.Content, "原文") || !strings.Contains(last.Content, "system-reminder") { + t.Fatalf("reminder not folded into tool result: %+v", last) + } + if strings.Contains(out[0].Content, "system-reminder") { + t.Fatal("reminder leaked onto the earlier user message") + } +} + +func TestAdmitThrottles(t *testing.T) { + bus := New(time.Minute, 2) + now := time.Now() + if !bus.Admit(Note{Kind: KindBudget, Key: "near", Text: "预算将尽"}, now) { + t.Fatal("first admit") + } + if bus.Admit(Note{Kind: KindBudget, Key: "near", Text: "预算将尽"}, now.Add(time.Second)) { + t.Fatal("duplicate key admitted") + } + if bus.Admit(Note{Kind: KindBackground, Key: "job", Text: "后台结束"}, now.Add(time.Second)) { + t.Fatal("inside min gap admitted") + } + if !bus.Admit(Note{Kind: KindBackground, Key: "job", Text: "后台结束"}, now.Add(time.Minute)) { + t.Fatal("after gap") + } + if bus.Admit(Note{Kind: KindDeliverable, Key: "doc", Text: "交付物待确认"}, now.Add(2*time.Minute)) { + t.Fatal("over cap") + } +} diff --git a/runtime/session/compact.go b/runtime/session/compact.go index 90d4186..8975172 100644 --- a/runtime/session/compact.go +++ b/runtime/session/compact.go @@ -67,15 +67,35 @@ func (s *Store) ReadMeta() map[string]string { return meta } +const maxCompactAttempts = 3 + // Compact appends a compaction entry. The file keeps every earlier record. // Load hides the summarized prefix behind the summary plus the last // keepLastN non-system messages. Returns false when there is nothing to fold. +// A session change during summary is retried from the new transcript. +// Three consecutive summary failures or conflicts trip the breaker and +// leave the file unchanged. func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error) { + var last error + for attempt := 0; attempt < maxCompactAttempts; attempt++ { + done, err, retry := s.compactOnce(keepLastN, summarize) + if !retry { + return done, err + } + last = err + } + if last == nil { + last = fmt.Errorf("compaction conflict: session changed during summary") + } + return false, fmt.Errorf("compaction circuit open: %w", last) +} + +func (s *Store) compactOnce(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error, bool) { s.mu.Lock() entries, err := s.readEntriesLocked() if err != nil { s.mu.Unlock() - return false, err + return false, err, false } leaf := s.leafLocked(entries) generation := len(entries) @@ -90,7 +110,7 @@ func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (s } } if len(messages) <= keepLastN+1 { - return false, nil + return false, nil, false } rest := messages restEntries := msgEntries @@ -100,13 +120,13 @@ func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (s } tailStart := len(rest) - keepLastN if tailStart <= 0 { - return false, nil + return false, nil, false } tailStart = alignToolTail(rest, tailStart) head := rest[:tailStart] summary, err := summarize(head) if err != nil { - return false, err + return false, err, true } var tailIDs []string for _, e := range restEntries[tailStart:] { @@ -116,13 +136,13 @@ func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (s defer s.mu.Unlock() now, err := s.readEntriesLocked() if err != nil { - return false, err + return false, err, false } if len(now) != generation { - return false, fmt.Errorf("compaction conflict: session changed during summary") + return false, fmt.Errorf("compaction conflict: session changed during summary"), true } _, err = s.appendEntryLocked(Entry{Type: TypeCompaction, Summary: summary, TailIDs: tailIDs}) - return err == nil, err + return err == nil, err, false } // alignToolTail 让保留尾巴从一条非 tool 消息开始,避免把 tool_calls 和结果拆开。 diff --git a/runtime/session/microcompact.go b/runtime/session/microcompact.go new file mode 100644 index 0000000..075eb70 --- /dev/null +++ b/runtime/session/microcompact.go @@ -0,0 +1,70 @@ +package session + +import "agent-native/runtime/llm" + +const ( + // DefaultKeepToolResults 是运行内清理时保留的最近工具结果条数。 + DefaultKeepToolResults = 5 + // DefaultContextWindow 是未配置模型窗口时的保守字符数。 + DefaultContextWindow = 32000 + // DefaultOutputReserve 是留给模型输出的字符预留。 + DefaultOutputReserve = 4096 + // DefaultContextBuffer 是窗口估算的余量,避免贴着上限才清理。 + DefaultContextBuffer = 1024 +) + +// EstimateChars 用字符数近似这次请求的体积。 +func EstimateChars(msgs []llm.Message) int { + n := 0 + for _, m := range msgs { + n += len([]rune(m.Content)) + for _, c := range m.ToolCalls { + n += len([]rune(c.Name)) + len([]rune(c.Arguments)) + } + } + return n +} + +// NeedsClear 在估算超过「窗口 − 输出预留 − 缓冲」时返回真。 +// window ≤ 0 时用保守默认值。 +func NeedsClear(estimate, window, outputReserve, buffer int) bool { + if window <= 0 { + window = DefaultContextWindow + } + room := window - outputReserve - buffer + if room < 1 { + room = 1 + } + return estimate > room +} + +// ClearOldToolResults 把最近 keep 条之前的工具结果换成占位。 +// 消息条数、顺序和 tool_call 配对都不变;占位写明完整结果仍在会话记录里。 +// keep ≤ 0 时保留默认的 5 条。返回的切片不与输入共享被改写的消息。 +func ClearOldToolResults(msgs []llm.Message, keep int) []llm.Message { + if keep <= 0 { + keep = DefaultKeepToolResults + } + out := append([]llm.Message{}, msgs...) + var toolIdx []int + for i, m := range out { + if m.Role == llm.RoleTool { + toolIdx = append(toolIdx, i) + } + } + if len(toolIdx) <= keep { + return out + } + for _, i := range toolIdx[:len(toolIdx)-keep] { + name := out[i].Name + if name == "" { + name = "工具" + } + loc := "会话记录" + if out[i].ToolCallID != "" { + loc = "会话记录中的调用 " + out[i].ToolCallID + } + out[i].Content = "旧工具结果已清理。完整内容见" + loc + "(" + name + ")。" + } + return out +} diff --git a/runtime/session/microcompact_test.go b/runtime/session/microcompact_test.go new file mode 100644 index 0000000..2288c36 --- /dev/null +++ b/runtime/session/microcompact_test.go @@ -0,0 +1,60 @@ +package session + +import ( + "strings" + "testing" + + "agent-native/runtime/llm" +) + +func TestClearOldToolResultsKeepsLastFive(t *testing.T) { + var msgs []llm.Message + for i := 0; i < 8; i++ { + id := string(rune('a' + i)) + msgs = append(msgs, + llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: id, Name: "read_file"}}}, + llm.Message{Role: llm.RoleTool, Name: "read_file", ToolCallID: id, Content: "正文-" + id}, + ) + } + out := ClearOldToolResults(msgs, 5) + if len(out) != len(msgs) { + t.Fatalf("len %d, want %d", len(out), len(msgs)) + } + cleared := 0 + for i, m := range out { + if m.Role != llm.RoleTool { + if m.Role != msgs[i].Role || len(m.ToolCalls) != len(msgs[i].ToolCalls) { + t.Fatalf("pairing changed at %d", i) + } + continue + } + if m.ToolCallID != msgs[i].ToolCallID { + t.Fatalf("tool id changed at %d", i) + } + if strings.Contains(m.Content, "旧工具结果已清理") { + cleared++ + if !strings.Contains(m.Content, m.ToolCallID) { + t.Fatalf("placeholder missing call id: %s", m.Content) + } + } else if m.Content != msgs[i].Content { + t.Fatalf("recent result changed: %s", m.Content) + } + } + if cleared != 3 { + t.Fatalf("cleared %d, want 3", cleared) + } + for _, m := range out[len(out)-10:] { + if m.Role == llm.RoleTool && strings.Contains(m.Content, "旧工具结果已清理") { + t.Fatalf("last five were cleared: %s", m.ToolCallID) + } + } +} + +func TestNeedsClearUsesConservativeWindow(t *testing.T) { + if NeedsClear(10, 0, 0, 0) { + t.Fatal("small estimate must fit the default window") + } + if !NeedsClear(DefaultContextWindow, 0, DefaultOutputReserve, DefaultContextBuffer) { + t.Fatal("estimate at the default window must clear") + } +} diff --git a/runtime/session/r2_regression_test.go b/runtime/session/r2_regression_test.go index df0da96..b1f8bc8 100644 --- a/runtime/session/r2_regression_test.go +++ b/runtime/session/r2_regression_test.go @@ -1,6 +1,7 @@ package session import ( + "fmt" "path/filepath" "strings" "testing" @@ -62,6 +63,104 @@ func TestR2CompactRejectsAppendDuringSummary(t *testing.T) { } } +func TestCompactRetriesThenCommits(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "s.jsonl")) + if err != nil { + t.Fatal(err) + } + for _, text := range []string{"a", "b", "c"} { + if err := s.Append(llm.Message{Role: llm.RoleUser, Content: text}); err != nil { + t.Fatal(err) + } + } + raced := false + ok, err := s.Compact(1, func(messages []llm.Message) (string, error) { + if !raced { + raced = true + if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "raced"}); err != nil { + return "", err + } + return "", fmt.Errorf("compaction conflict: session changed during summary") + } + return "summary", nil + }) + if err != nil || !ok { + t.Fatalf("retry compact: %v %v", ok, err) + } + loaded, err := s.Load() + if err != nil { + t.Fatal(err) + } + var saw bool + for _, m := range loaded { + if m.Content == "raced" || m.Content == "summary" { + saw = true + } + } + if !saw { + t.Fatalf("retried summary missing: %+v", loaded) + } +} + +func TestCompactLeavesNoOrphanToolCall(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "s.jsonl")) + if err != nil { + t.Fatal(err) + } + msgs := []llm.Message{ + {Role: llm.RoleUser, Content: "先看"}, + {Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "old", Name: "read_file"}}}, + {Role: llm.RoleTool, ToolCallID: "old", Name: "read_file", Content: "旧"}, + {Role: llm.RoleUser, Content: "再改"}, + {Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "a", Name: "read_file"}, {ID: "b", Name: "grep"}}}, + {Role: llm.RoleTool, ToolCallID: "a", Name: "read_file", Content: "甲"}, + {Role: llm.RoleTool, ToolCallID: "b", Name: "grep", Content: "乙"}, + } + for _, m := range msgs { + if err := s.Append(m); err != nil { + t.Fatal(err) + } + } + ok, err := s.Compact(2, func(messages []llm.Message) (string, error) { + return "摘要", nil + }) + if err != nil || !ok { + t.Fatalf("compact: %v %v", ok, err) + } + loaded, err := s.Load() + if err != nil { + t.Fatal(err) + } + assertToolPairs(t, loaded) +} + +func assertToolPairs(t *testing.T, msgs []llm.Message) { + t.Helper() + calls := map[string]int{} + for _, m := range msgs { + if m.Role != llm.RoleAssistant { + continue + } + for _, c := range m.ToolCalls { + calls[c.ID]++ + } + } + for _, m := range msgs { + if m.Role != llm.RoleTool { + continue + } + if calls[m.ToolCallID] == 0 { + t.Fatalf("orphan tool result %s in %+v", m.ToolCallID, msgs) + } + calls[m.ToolCallID]-- + } + for id, n := range calls { + if n != 0 { + t.Fatalf("orphan tool call %s in %+v", id, msgs) + } + } +} + func TestR2AppendVisibleAfterReopen(t *testing.T) { path := filepath.Join(t.TempDir(), "s.jsonl") s, err := Open(path) diff --git a/runtime/session_agent.go b/runtime/session_agent.go index 1f07f21..44c0a22 100644 --- a/runtime/session_agent.go +++ b/runtime/session_agent.go @@ -83,6 +83,13 @@ func (a *AgentSession) ClearQueue() { a.emitQueue("clear") } +// Busy 报告是否有尚未结束的 Prompt。 +func (a *AgentSession) Busy() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.cancel != nil +} + // Abort cancels the in-flight Prompt. func (a *AgentSession) Abort() { a.mu.Lock() diff --git a/runtime/subagent/subagent.go b/runtime/subagent/subagent.go index f7c2199..14a2d95 100644 --- a/runtime/subagent/subagent.go +++ b/runtime/subagent/subagent.go @@ -13,6 +13,7 @@ import ( "sort" "strings" "time" + "unicode/utf8" "agent-native/runtime/tools" ) @@ -26,8 +27,8 @@ type Request struct { DoneCriteria string `json:"done_criteria,omitempty"` Deliverables []string `json:"deliverables,omitempty"` // Deadline / MaxToolCalls 是宿主注入的预算余量(模型不可自行扩权)。 - Deadline time.Time `json:"-"` - MaxToolCalls int `json:"-"` + Deadline time.Time `json:"-"` + MaxToolCalls int `json:"-"` } // Result 是子代理的回传:至少包含结论文本;文件由宿主按写范围物化。 @@ -81,6 +82,15 @@ func (t *delegateTool) Execute(ctx context.Context, args json.RawMessage) (tools if err != nil { return tools.Result{Content: fmt.Sprintf("sub-agent failed: %v", err), IsError: true}, nil } + shown, full := CapParentText(res.Text) + if full != nil { + if res.Files == nil { + res.Files = map[string][]byte{} + } + res.Files["subagent-result.txt"] = full + shown += "\n完整结果见 subagent-result.txt" + } + res.Text = shown var b strings.Builder if res.Success { b.WriteString("[sub-agent succeeded]\n") @@ -101,3 +111,14 @@ func (t *delegateTool) Execute(ctx context.Context, args json.RawMessage) (tools } return tools.Result{Content: b.String(), IsError: !res.Success}, nil } + +// ParentResultCap 是回传父会话的默认字符上限。 +const ParentResultCap = 32000 + +// CapParentText 超出上限时返回截断文本和全文,由调用方把全文写成工件或文件。 +func CapParentText(text string) (shown string, full []byte) { + if utf8.RuneCountInString(text) <= ParentResultCap { + return text, nil + } + return string([]rune(text)[:ParentResultCap]), []byte(text) +} diff --git a/runtime/subagent/subagent_test.go b/runtime/subagent/subagent_test.go index 1c58bfb..aa93b90 100644 --- a/runtime/subagent/subagent_test.go +++ b/runtime/subagent/subagent_test.go @@ -84,3 +84,26 @@ func TestDelegateToolFactoryErrorAndCancel(t *testing.T) { t.Fatalf("pre-canceled delegation must not start, got %+v", res) } } + +func TestCapParentTextWritesOverflowPath(t *testing.T) { + shown, full := CapParentText("短") + if shown != "短" || full != nil { + t.Fatalf("short: %q %d", shown, len(full)) + } + body := strings.Repeat("字", ParentResultCap+3) + shown, full = CapParentText(body) + if full == nil || string(full) != body { + t.Fatal("full text not returned") + } + if len([]rune(shown)) != ParentResultCap { + t.Fatalf("shown runes %d", len([]rune(shown))) + } + reg := tools.NewRegistry(t.TempDir()) + Register(reg, func(ctx context.Context, req Request) (Result, error) { + return Result{Success: true, Text: body}, nil + }) + res := reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"goal":"g","done_criteria":"d"}`)) + if res.IsError || !strings.Contains(res.Content, "subagent-result.txt") { + t.Fatalf("capped result: %+v", res) + } +} diff --git a/runtime/tools/builtin.go b/runtime/tools/builtin.go index b1dd7f1..590278d 100644 --- a/runtime/tools/builtin.go +++ b/runtime/tools/builtin.go @@ -8,9 +8,14 @@ import ( "path/filepath" "regexp" "strings" + + "agent-native/runtime/filestate" ) -type readFile struct{ root string } +type readFile struct { + root string + files *filestate.Tracker +} func (t *readFile) Name() string { return "read_file" } func (t *readFile) Description() string { @@ -46,6 +51,7 @@ func (t *readFile) Execute(_ context.Context, args json.RawMessage) (Result, err if truncated { content += "\n[truncated at 200 KiB; use offset/limit to page through the file]" } + _ = t.files.NoteRead(path) return Result{Content: content}, nil } if obj.Offset <= 0 { @@ -66,10 +72,14 @@ func (t *readFile) Execute(_ context.Context, args json.RawMessage) (Result, err for i := obj.Offset - 1; i < end; i++ { out = append(out, fmt.Sprintf("%d\t%s", i+1, lines[i])) } + _ = t.files.NoteRead(path) return Result{Content: strings.Join(out, "\n")}, nil } -type writeFile struct{ root string } +type writeFile struct { + root string + files *filestate.Tracker +} func (t *writeFile) Name() string { return "write_file" } func (t *writeFile) Description() string { @@ -90,6 +100,9 @@ func (t *writeFile) Execute(_ context.Context, args json.RawMessage) (Result, er if err != nil { return Result{Content: err.Error(), IsError: true}, nil } + if msg := rejectStaleWrite(t.files, path); msg != "" { + return Result{Content: msg, IsError: true}, nil + } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return Result{Content: err.Error(), IsError: true}, nil } @@ -99,7 +112,10 @@ func (t *writeFile) Execute(_ context.Context, args json.RawMessage) (Result, er return Result{Content: "wrote " + obj.Path}, nil } -type editFile struct{ root string } +type editFile struct { + root string + files *filestate.Tracker +} func (t *editFile) Name() string { return "edit_file" } func (t *editFile) Description() string { @@ -124,6 +140,9 @@ func (t *editFile) Execute(_ context.Context, args json.RawMessage) (Result, err if err != nil { return Result{Content: err.Error(), IsError: true}, nil } + if msg := rejectStaleWrite(t.files, path); msg != "" { + return Result{Content: msg, IsError: true}, nil + } b, err := os.ReadFile(path) if err != nil { return Result{Content: err.Error(), IsError: true}, nil @@ -262,7 +281,10 @@ func (t *grepTool) Execute(_ context.Context, args json.RawMessage) (Result, err } // multiEditTool 一次多处替换:全部命中且互不冲突才落盘,任一失败即整体不动。 -type multiEditTool struct{ root string } +type multiEditTool struct { + root string + files *filestate.Tracker +} func (t *multiEditTool) Name() string { return "multi_edit" } func (t *multiEditTool) Description() string { @@ -292,6 +314,9 @@ func (t *multiEditTool) Execute(_ context.Context, args json.RawMessage) (Result if err != nil { return Result{Content: err.Error(), IsError: true}, nil } + if msg := rejectStaleWrite(t.files, path); msg != "" { + return Result{Content: msg, IsError: true}, nil + } b, err := os.ReadFile(path) if err != nil { return Result{Content: err.Error(), IsError: true}, nil diff --git a/runtime/tools/filestate_test.go b/runtime/tools/filestate_test.go new file mode 100644 index 0000000..67b2064 --- /dev/null +++ b/runtime/tools/filestate_test.go @@ -0,0 +1,38 @@ +package tools + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "agent-native/runtime/filestate" +) + +func TestTrackedWriteRejectsUnreadAndStale(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "note.txt") + if err := os.WriteFile(path, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + reg := WorkspaceToolsTracked(root, filestate.New()) + res := runTool(t, reg, "write_file", `{"path":"note.txt","content":"v2"}`) + if !res.IsError || !strings.Contains(res.Content, "请先读取") { + t.Fatalf("unread: %+v", res) + } + res = runTool(t, reg, "read_file", `{"path":"note.txt"}`) + if res.IsError { + t.Fatal(res.Content) + } + if err := os.WriteFile(path, []byte("v1-external"), 0o644); err != nil { + t.Fatal(err) + } + res = runTool(t, reg, "edit_file", `{"path":"note.txt","old":"v1","new":"v2"}`) + if !res.IsError || !strings.Contains(res.Content, "请重新读取") { + t.Fatalf("stale: %+v", res) + } + res = runTool(t, reg, "write_file", `{"path":"fresh.txt","content":"ok"}`) + if res.IsError { + t.Fatalf("create: %+v", res) + } +} diff --git a/runtime/tools/registry.go b/runtime/tools/registry.go index 44c382c..791c1bf 100644 --- a/runtime/tools/registry.go +++ b/runtime/tools/registry.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + "agent-native/runtime/filestate" ) // Result is the tool payload returned to the model. @@ -269,17 +271,32 @@ func matchesType(kind string, v any) bool { // WorkspaceTools registers the document workspace tool set. func WorkspaceTools(root string) *Registry { + return WorkspaceToolsTracked(root, nil) +} + +// WorkspaceToolsTracked 与 WorkspaceTools 相同,但改写已存在的文件前要先在本轮读过。 +func WorkspaceToolsTracked(root string, files *filestate.Tracker) *Registry { r := NewRegistry(root) - r.Register(&readFile{root: root}) - r.Register(&writeFile{root: root}) - r.Register(&editFile{root: root}) - r.Register(&multiEditTool{root: root}) + r.Register(&readFile{root: root, files: files}) + r.Register(&writeFile{root: root, files: files}) + r.Register(&editFile{root: root, files: files}) + r.Register(&multiEditTool{root: root, files: files}) r.Register(&listDir{root: root}) r.Register(&grepTool{root: root}) r.Register(&globTool{root: root}) return r } +func rejectStaleWrite(files *filestate.Tracker, path string) string { + if files == nil { + return "" + } + if err := files.CheckWrite(path); err != nil { + return err.Error() + } + return "" +} + func mustPath(root, rel string) (string, error) { return Resolve(root, rel) } diff --git a/runtime/tools/select.go b/runtime/tools/select.go new file mode 100644 index 0000000..b75dd13 --- /dev/null +++ b/runtime/tools/select.go @@ -0,0 +1,66 @@ +package tools + +import "strings" + +// ApplySelection 按 +name / -name 调整默认工具集。 +// 规格为空时返回 base 的副本。出现不带正负号的名字时,先用这些名字替换默认集,再应用增减。 +// 分隔符为空格、逗号或换行。 +func ApplySelection(base []string, spec string) []string { + spec = strings.TrimSpace(spec) + if spec == "" { + return append([]string{}, base...) + } + fields := strings.FieldsFunc(spec, func(r rune) bool { + return r == ',' || r == ' ' || r == '\n' || r == '\t' + }) + hasBare := false + for _, f := range fields { + if f != "" && f[0] != '+' && f[0] != '-' { + hasBare = true + break + } + } + var out []string + if !hasBare { + out = append(out, base...) + } + for _, f := range fields { + if f == "" { + continue + } + switch f[0] { + case '+': + out = addName(out, f[1:]) + case '-': + out = dropName(out, f[1:]) + default: + out = addName(out, f) + } + } + return out +} + +func addName(names []string, name string) []string { + if name == "" { + return names + } + for _, n := range names { + if n == name { + return names + } + } + return append(names, name) +} + +func dropName(names []string, name string) []string { + if name == "" { + return names + } + out := names[:0] + for _, n := range names { + if n != name { + out = append(out, n) + } + } + return out +} diff --git a/runtime/tools/select_test.go b/runtime/tools/select_test.go new file mode 100644 index 0000000..4878aac --- /dev/null +++ b/runtime/tools/select_test.go @@ -0,0 +1,24 @@ +package tools + +import "testing" + +func TestApplySelectionDeltas(t *testing.T) { + base := []string{"read_file", "write_file", "grep"} + got := ApplySelection(base, "-write_file,+edit_file") + want := []string{"read_file", "grep", "edit_file"} + if len(got) != len(want) { + t.Fatalf("%v", got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v want %v", got, want) + } + } + replaced := ApplySelection(base, "read_file, grep") + if len(replaced) != 2 || replaced[0] != "read_file" || replaced[1] != "grep" { + t.Fatalf("bare list: %v", replaced) + } + if same := ApplySelection(base, ""); len(same) != 3 { + t.Fatalf("empty spec: %v", same) + } +}