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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 和结果拆开。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user