Spill cleared tool results to disk and pin the cache-stability contract (N2 WP1)
Why: the completion doc's N2 requires old tool results to become file paths (ZCode compact pattern) and cache-friendly prefix rules; today the micro-compactor only left a transcript pointer and nothing pinned prefix stability. alignToolTail had no direct edge tests. Evidence: GOWORK=off go vet + go test -count=1 ./... green; new TestClearOldToolResultsSpillsToDisk / TestSpillResultSanitizesCallID / TestAlignToolTailEdges / TestSystemTextDeterministicAcrossIdenticalBuilds; legacy placeholder bytes unchanged when ResultsDir is empty (TestClearOldToolResultsEmptyDirKeepsLegacyPlaceholder). Impact: opt-in via Config.ResultsDir; the native adapter wires it to <workDir>/.agentx/results. No behavior change for other hosts. Refs: 2026-10-07-agent-stack-n2-plan WP1
This commit is contained in:
@@ -380,6 +380,7 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
||||
Budget: budget,
|
||||
Reminders: b.opts.Reminders,
|
||||
RepeatGuard: b.opts.RepeatGuard,
|
||||
ResultsDir: filepath.Join(workDir, ".agentx", "results"),
|
||||
Hooks: gateBus(b.opts.GateOptions),
|
||||
PermissionAsk: b.opts.PermissionAsk,
|
||||
Trace: b.opts.Trace,
|
||||
|
||||
+4
-1
@@ -108,6 +108,9 @@ type Config struct {
|
||||
ContextBuffer int
|
||||
// KeepToolResults 是清理时保留的最近工具结果条数。0 表示 5。
|
||||
KeepToolResults int
|
||||
// ResultsDir 非空时,被清理的旧工具结果完整内容落盘到该目录
|
||||
// (占位写路径);空则占位指向会话记录(旧行为)。
|
||||
ResultsDir string
|
||||
// Hooks 是进程内钩子。空总线不改变停止、提醒和拒绝次数。
|
||||
Hooks *hooks.Bus
|
||||
// PermissionAsk 在钩子对一次工具调用表态 ask 时向宿主请求裁决。
|
||||
@@ -772,7 +775,7 @@ func clearOldResults(cfg Config, messages []llm.Message) []llm.Message {
|
||||
if !session.NeedsClear(session.EstimateChars(messages), window, reserve, buffer) {
|
||||
return messages
|
||||
}
|
||||
return session.ClearOldToolResults(messages, cfg.KeepToolResults)
|
||||
return session.ClearOldToolResultsTo(messages, cfg.KeepToolResults, cfg.ResultsDir)
|
||||
}
|
||||
|
||||
func turnReminderTexts(cfg Config, turn int, repeats *repeatState, lengthNudge *bool) []string {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// ---- N2 WP1:前缀稳定契约(缓存友好的机器钉住:同一注册序列 → 逐字节确定)----
|
||||
|
||||
type contractTool struct {
|
||||
name, desc string
|
||||
deferred bool
|
||||
}
|
||||
|
||||
func (t contractTool) Name() string { return t.name }
|
||||
func (t contractTool) Description() string { return t.desc }
|
||||
func (t contractTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object","properties":{}}`) }
|
||||
func (t contractTool) Execute(_ context.Context, _ json.RawMessage) (tools.Result, error) {
|
||||
return tools.Result{Content: "ok"}, nil
|
||||
}
|
||||
func (t contractTool) Exposure() string {
|
||||
if t.deferred {
|
||||
return tools.ExposureDeferred
|
||||
}
|
||||
return tools.ExposureDirect
|
||||
}
|
||||
|
||||
func buildContractRegistry() *tools.Registry {
|
||||
reg := tools.NewRegistry("")
|
||||
reg.Register(contractTool{name: "read_file", desc: "读文件"})
|
||||
reg.Register(contractTool{name: "write_file", desc: "写文件"})
|
||||
reg.Register(contractTool{name: "grep", desc: "搜索", deferred: true})
|
||||
return reg
|
||||
}
|
||||
|
||||
func TestSystemTextDeterministicAcrossIdenticalBuilds(t *testing.T) {
|
||||
a := systemText(Config{SystemPrompt: "sys", Tools: buildContractRegistry()})
|
||||
b := systemText(Config{SystemPrompt: "sys", Tools: buildContractRegistry()})
|
||||
if a != b {
|
||||
t.Fatalf("同一注册序列的系统文本必须逐字节一致:\n%q\nvs\n%q", a, b)
|
||||
}
|
||||
// 契约内容:直接工具全在清单内且按注册序;deferred 工具不进入清单。
|
||||
idx := strings.Index(a, "read_file")
|
||||
if idx < 0 || strings.Index(a, "write_file") < idx {
|
||||
t.Fatalf("直接工具须按注册序出现:%q", a)
|
||||
}
|
||||
if strings.Contains(a, "grep") {
|
||||
t.Fatalf("deferred 工具不得出现在系统文本:%q", a)
|
||||
}
|
||||
// 元工具注入位置确定(tool_search/call_tool 存在且在 Decls 中)。
|
||||
decls := buildContractRegistry().Decls()
|
||||
names := []string{}
|
||||
for _, d := range decls {
|
||||
names = append(names, d.Name)
|
||||
}
|
||||
if len(names) != 4 || names[0] != "read_file" || names[1] != "write_file" {
|
||||
t.Fatalf("Decls 顺序契约:%v", names)
|
||||
}
|
||||
if names[2] != "tool_search" && names[3] != "tool_search" {
|
||||
if names[2] != "call_tool" && names[3] != "call_tool" {
|
||||
t.Fatalf("元工具缺失:%v", names)
|
||||
}
|
||||
}
|
||||
var _ = llm.ToolDecl{} // 保持导入面稳定
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
package session
|
||||
|
||||
import "agent-native/runtime/llm"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultKeepToolResults 是运行内清理时保留的最近工具结果条数。
|
||||
@@ -42,6 +48,14 @@ func NeedsClear(estimate, window, outputReserve, buffer int) bool {
|
||||
// 消息条数、顺序和 tool_call 配对都不变;占位写明完整结果仍在会话记录里。
|
||||
// keep ≤ 0 时保留默认的 5 条。返回的切片不与输入共享被改写的消息。
|
||||
func ClearOldToolResults(msgs []llm.Message, keep int) []llm.Message {
|
||||
return ClearOldToolResultsTo(msgs, keep, "")
|
||||
}
|
||||
|
||||
// ClearOldToolResultsTo 在 ClearOldToolResults 的基础上支持落盘:
|
||||
// dir 非空时,把被清理结果的完整内容写进 <dir>/<toolCallID>.txt,
|
||||
// 占位改为路径指引(ZCode 式「旧工具结果换成路径」);
|
||||
// 写盘失败或调用 ID 为空时回落为转录指针占位,不阻断本轮。
|
||||
func ClearOldToolResultsTo(msgs []llm.Message, keep int, dir string) []llm.Message {
|
||||
if keep <= 0 {
|
||||
keep = DefaultKeepToolResults
|
||||
}
|
||||
@@ -60,6 +74,12 @@ func ClearOldToolResults(msgs []llm.Message, keep int) []llm.Message {
|
||||
if name == "" {
|
||||
name = "工具"
|
||||
}
|
||||
if dir != "" && out[i].ToolCallID != "" && out[i].Content != "" {
|
||||
if path, ok := spillResult(dir, out[i].ToolCallID, out[i].Content); ok {
|
||||
out[i].Content = "旧工具结果已清理。完整内容见 " + path + "(" + name + ")。"
|
||||
continue
|
||||
}
|
||||
}
|
||||
loc := "会话记录"
|
||||
if out[i].ToolCallID != "" {
|
||||
loc = "会话记录中的调用 " + out[i].ToolCallID
|
||||
@@ -68,3 +88,28 @@ func ClearOldToolResults(msgs []llm.Message, keep int) []llm.Message {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// spillResult 把一条旧工具结果写盘,返回路径。文件名只留安全字符。
|
||||
func spillResult(dir, callID, content string) (string, bool) {
|
||||
var sb strings.Builder
|
||||
for _, r := range callID {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
|
||||
r == '-' || r == '_' || r == '.':
|
||||
sb.WriteRune(r)
|
||||
default:
|
||||
sb.WriteByte('_')
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "", false
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", false
|
||||
}
|
||||
path := filepath.Join(dir, sb.String()+".txt")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -58,3 +60,77 @@ func TestNeedsClearUsesConservativeWindow(t *testing.T) {
|
||||
t.Fatal("estimate at the default window must clear")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N2 WP1:旧工具结果落盘(路径占位)----
|
||||
|
||||
func spillFixture() []llm.Message {
|
||||
msgs := []llm.Message{{Role: llm.RoleUser, Content: "u"}}
|
||||
for i := 0; i < 7; i++ {
|
||||
id := "call-" + string(rune('a'+i))
|
||||
msgs = append(msgs,
|
||||
llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: id, Name: "read", Arguments: "{}"}}},
|
||||
llm.Message{Role: llm.RoleTool, ToolCallID: id, Name: "read", Content: "完整结果内容-" + id})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
func TestClearOldToolResultsSpillsToDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := ClearOldToolResultsTo(spillFixture(), 5, dir)
|
||||
if len(out) != len(spillFixture()) {
|
||||
t.Fatal("消息条数不得变")
|
||||
}
|
||||
// 最旧两条被落盘,占位带路径。
|
||||
for i, id := range []string{"call-a", "call-b"} {
|
||||
path := filepath.Join(dir, id+".txt")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("落盘文件缺失:%v", err)
|
||||
}
|
||||
if string(b) != "完整结果内容-"+id {
|
||||
t.Fatalf("落盘内容不符:%q", b)
|
||||
}
|
||||
placeholder := out[2+i*2].Content
|
||||
if !strings.Contains(placeholder, path) {
|
||||
t.Fatalf("占位未带路径:%q", placeholder)
|
||||
}
|
||||
}
|
||||
// 保留的最近 5 条正文不动。
|
||||
if out[len(out)-1].Content != "完整结果内容-call-g" {
|
||||
t.Fatalf("最近结果被误改:%q", out[len(out)-1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearOldToolResultsEmptyDirKeepsLegacyPlaceholder(t *testing.T) {
|
||||
out := ClearOldToolResultsTo(spillFixture(), 5, "")
|
||||
legacy := ClearOldToolResults(spillFixture(), 5)
|
||||
for i := range out {
|
||||
if out[i].Content != legacy[i].Content {
|
||||
t.Fatalf("空 dir 应与旧行为逐字节一致:msg %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearOldToolResultsSpillFailureFallsBack(t *testing.T) {
|
||||
// 不可写目录:占位回落为转录指针,不阻断。
|
||||
blocked := filepath.Join(t.TempDir(), "file")
|
||||
if err := os.WriteFile(blocked, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := ClearOldToolResultsTo(spillFixture(), 5, filepath.Join(blocked, "sub"))
|
||||
if !strings.Contains(out[2].Content, "会话记录中的调用 call-a") {
|
||||
t.Fatalf("写盘失败应回落转录指针:%q", out[2].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpillResultSanitizesCallID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path, ok := spillResult(dir, "call/../evil\x00id", "x")
|
||||
if !ok {
|
||||
t.Fatal("spill 失败")
|
||||
}
|
||||
base := filepath.Base(path)
|
||||
if strings.Contains(base, "/") || strings.ContainsRune(base, 0) || filepath.Dir(path) != dir {
|
||||
t.Fatalf("文件名未消毒或越出目录:%q", path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,3 +182,42 @@ func TestR2AppendVisibleAfterReopen(t *testing.T) {
|
||||
t.Fatalf("reopen: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N2 WP1:alignToolTail 边界直接单测(原仅经 Store.Compact 间接覆盖)----
|
||||
|
||||
func tailMsgs() []llm.Message {
|
||||
call := func(id string) llm.ToolCall { return llm.ToolCall{ID: id, Name: "read", Arguments: "{}"} }
|
||||
return []llm.Message{
|
||||
{Role: llm.RoleUser, Content: "u1"}, // 0
|
||||
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{call("a"), call("b")}}, // 1 多调用 assistant
|
||||
{Role: llm.RoleTool, ToolCallID: "a", Content: "ra"}, // 2
|
||||
{Role: llm.RoleTool, ToolCallID: "b", Content: "rb"}, // 3
|
||||
{Role: llm.RoleUser, Content: "u2"}, // 4
|
||||
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{call("c")}}, // 5
|
||||
{Role: llm.RoleTool, ToolCallID: "c", Content: "rc"}, // 6
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlignToolTailEdges(t *testing.T) {
|
||||
msgs := tailMsgs()
|
||||
// tail 恰落在多调用 assistant 的结果组内 → 回退到该 assistant。
|
||||
if got := alignToolTail(msgs, 3); got != 1 {
|
||||
t.Fatalf("落在多调用结果组内:got %d want 1", got)
|
||||
}
|
||||
// tail 落在结果组边界外(user)→ 不动。
|
||||
if got := alignToolTail(msgs, 4); got != 4 {
|
||||
t.Fatalf("落在 user 上:got %d want 4", got)
|
||||
}
|
||||
// 结尾整组是 tool 角色 → 回退到其 assistant。
|
||||
if got := alignToolTail(msgs, 6); got != 5 {
|
||||
t.Fatalf("落在末尾结果上:got %d want 5", got)
|
||||
}
|
||||
// 从头起全是 tool 角色(退化形态)→ 回退到 0,不越界。
|
||||
allTool := []llm.Message{
|
||||
{Role: llm.RoleTool, ToolCallID: "x", Content: "1"},
|
||||
{Role: llm.RoleTool, ToolCallID: "y", Content: "2"},
|
||||
}
|
||||
if got := alignToolTail(allTool, 1); got != 0 {
|
||||
t.Fatalf("全 tool:got %d want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user