Keep tool pairs and fold reminders into the last result #12

Merged
weichen merged 1 commits from feature/context-engineering into main 2026-10-03 16:25:36 +00:00
20 changed files with 810 additions and 42 deletions
+2 -1
View File
@@ -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)
+24 -4
View File
@@ -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 {
+5 -1
View File
@@ -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 {
+63
View File
@@ -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
}
+40
View File
@@ -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)
}
}
+39 -19
View File
@@ -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: "<system-reminder>\n" + text + "\n</system-reminder>"}
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
+97
View File
@@ -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 := "<system-reminder>\n" + strings.Join(lines, "\n") + "\n</system-reminder>"
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
}
+53
View File
@@ -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")
}
}
+27 -7
View File
@@ -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 和结果拆开。
+70
View File
@@ -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
}
+60
View File
@@ -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")
}
}
+99
View File
@@ -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)
+7
View File
@@ -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()
+23 -2
View File
@@ -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)
}
+23
View File
@@ -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)
}
}
+29 -4
View File
@@ -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
+38
View File
@@ -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)
}
}
+21 -4
View File
@@ -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)
}
+66
View File
@@ -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
}
+24
View File
@@ -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)
}
}