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)
+ }
+}