diff --git a/adapters/native/backend.go b/adapters/native/backend.go
index 7a1686d..ae14e43 100644
--- a/adapters/native/backend.go
+++ b/adapters/native/backend.go
@@ -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,
diff --git a/runtime/loop.go b/runtime/loop.go
index a3df14d..5cda993 100644
--- a/runtime/loop.go
+++ b/runtime/loop.go
@@ -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 {
diff --git a/runtime/loop_cache_contract_test.go b/runtime/loop_cache_contract_test.go
new file mode 100644
index 0000000..ff8fbc3
--- /dev/null
+++ b/runtime/loop_cache_contract_test.go
@@ -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{} // 保持导入面稳定
+}
diff --git a/runtime/session/microcompact.go b/runtime/session/microcompact.go
index 075eb70..1c3e783 100644
--- a/runtime/session/microcompact.go
+++ b/runtime/session/microcompact.go
@@ -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 非空时,把被清理结果的完整内容写进
/.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
+}
diff --git a/runtime/session/microcompact_test.go b/runtime/session/microcompact_test.go
index 2288c36..1a8ac5f 100644
--- a/runtime/session/microcompact_test.go
+++ b/runtime/session/microcompact_test.go
@@ -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)
+ }
+}
diff --git a/runtime/session/r2_regression_test.go b/runtime/session/r2_regression_test.go
index b1f8bc8..f7fcede 100644
--- a/runtime/session/r2_regression_test.go
+++ b/runtime/session/r2_regression_test.go
@@ -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)
+ }
+}