Spill cleared tool results to disk and pin the cache-stability contract (N2 WP1)
ci / vet-and-test (pull_request) Successful in 52s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s

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:
2026-10-07 12:59:05 +00:00
parent ead690be30
commit ae42c9402d
6 changed files with 236 additions and 2 deletions
+1
View File
@@ -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
View File
@@ -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 {
+70
View File
@@ -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{} // 保持导入面稳定
}
+46 -1
View File
@@ -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
}
+76
View File
@@ -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)
}
}
+39
View File
@@ -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)
}
}