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) + } +}