diff --git a/runtime/durable_test.go b/runtime/durable_test.go index 435d893..6bc7fd3 100644 --- a/runtime/durable_test.go +++ b/runtime/durable_test.go @@ -208,3 +208,157 @@ func TestSpansUseAgreedNamesWithoutPrompt(t *testing.T) { t.Fatal("prompt leaked into span attrs") } } + +// ---- N3 WP1:崩溃窗口尾部配对兜底(验收① 库层机器核心)---- + +// 窗口内死亡(persist(assistant) 之后、任何 intent 之前):转录只有带 +// tool_calls 的 assistant。恢复后每个调用都有「进程中断」配对,且 run 继续。 +func TestResumePairsOrphanTailAfterCrashWindow(t *testing.T) { + dir := t.TempDir() + st, err := session.Open(filepath.Join(dir, "s.jsonl")) + if err != nil { + t.Fatal(err) + } + if err := st.Append(llm.Message{Role: llm.RoleSystem, Content: "sys"}); err != nil { + t.Fatal(err) + } + if err := st.Append(llm.Message{Role: llm.RoleUser, Content: "干个活"}); err != nil { + t.Fatal(err) + } + // 崩溃窗口形态:assistant 落盘、零意图、零结果。 + if err := st.Append(llm.Message{ + Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, + ToolCalls: []llm.ToolCall{ + {ID: "c1", Name: "write_file", Arguments: `{}`}, + {ID: "c2", Name: "read_file", Arguments: `{}`}, + }, + }); err != nil { + t.Fatal(err) + } + reg := tools.NewRegistry(dir) + tool := &countingTool{name: "write_file"} + reg.Register(tool) + reg.Register(&countingTool{name: "read_file"}) + _, err = Run(context.Background(), Config{ + Model: "fake", Session: st, Tools: reg, MaxTurns: 2, + Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "继续", StopReason: llm.StopEnd}), + }, llm.Message{Role: llm.RoleUser, Content: "还在吗"}) + if err != nil { + t.Fatalf("恢复后的 Run 不得失败(配对修复应让提供方接受历史):%v", err) + } + msgs, err := st.Load() + if err != nil { + t.Fatal(err) + } + // 双向配对断言:每个调用有结果、每个结果有调用。 + calls, results := map[string]string{}, map[string]bool{} + for _, m := range msgs { + for _, c := range m.ToolCalls { + calls[c.ID] = c.Name + } + if m.Role == llm.RoleTool && m.ToolCallID != "" { + results[m.ToolCallID] = true + if m.Content != crashInterrupted && m.Content != unknownSideEffect { + t.Fatalf("兜底文案不对:%q", m.Content) + } + if !m.IsError { + t.Fatal("兜底结果必须 IsError") + } + } + } + for id := range calls { + if !results[id] { + t.Fatalf("孤立 tool_call:%s", id) + } + } + if tool.calls != 0 { + t.Fatalf("兜底不得重放写工具:%d", tool.calls) + } +} + +// 执行中死亡(c1 意图+结果、c2 意图无结果)+ 窗口死亡(c3 无意图)混合: +// 意图路径配 c2、尾部兜底配 c3,互不重复。 +func TestResumeMixedIntentAndOrphanTail(t *testing.T) { + dir := t.TempDir() + st, err := session.Open(filepath.Join(dir, "s.jsonl")) + if err != nil { + t.Fatal(err) + } + if err := st.Append(llm.Message{ + Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, + ToolCalls: []llm.ToolCall{ + {ID: "c1", Name: "read_file", Arguments: `{}`}, + {ID: "c2", Name: "read_file", Arguments: `{}`}, + {ID: "c3", Name: "write_file", Arguments: `{}`}, + }, + }); err != nil { + t.Fatal(err) + } + if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil { + t.Fatal(err) + } + if err := st.Append(llm.Message{Role: llm.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "真实结果"}); err != nil { + t.Fatal(err) + } + if err := st.AppendToolIntent(session.ToolIntent{CallID: "c2", Name: "read_file", Arguments: `{}`, Replay: tools.ReplayNever}); err != nil { + t.Fatal(err) + } + reg := tools.NewRegistry(dir) + reg.Register(&countingTool{name: "read_file"}) + reg.Register(&countingTool{name: "write_file"}) + _, err = Run(context.Background(), Config{ + Model: "fake", Session: st, Tools: reg, MaxTurns: 1, + Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}), + }, llm.Message{Role: llm.RoleUser, Content: "next"}) + if err != nil { + t.Fatal(err) + } + msgs, err := st.Load() + if err != nil { + t.Fatal(err) + } + byID := map[string][]string{} + for _, m := range msgs { + if m.Role == llm.RoleTool && m.ToolCallID != "" { + byID[m.ToolCallID] = append(byID[m.ToolCallID], m.Content) + } + } + if got := byID["c1"]; len(got) != 1 || got[0] != "真实结果" { + t.Fatalf("c1 必须保持原结果且不重复:%v", got) + } + if got := byID["c2"]; len(got) != 1 || got[0] != unknownSideEffect { + t.Fatalf("c2 走意图路径:%v", got) + } + if got := byID["c3"]; len(got) != 1 || got[0] != crashInterrupted { + t.Fatalf("c3 走尾部兜底:%v", got) + } +} + +// 健康转录(尾部是完整配对或 user 消息)不做任何改写。 +func TestRepairOrphanTailNoopOnHealthyTranscript(t *testing.T) { + dir := t.TempDir() + st, err := session.Open(filepath.Join(dir, "s.jsonl")) + if err != nil { + t.Fatal(err) + } + for _, m := range []llm.Message{ + {Role: llm.RoleSystem, Content: "sys"}, + {Role: llm.RoleUser, Content: "u"}, + {Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}}}, + {Role: llm.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "r"}, + {Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + {Role: llm.RoleUser, Content: "u2"}, + } { + if err := st.Append(m); err != nil { + t.Fatal(err) + } + } + before, _ := st.Load() + if err := repairOrphanTail(Config{Session: st}); err != nil { + t.Fatal(err) + } + after, _ := st.Load() + if len(after) != len(before) { + t.Fatalf("健康转录被改写:%d → %d", len(before), len(after)) + } +} diff --git a/runtime/loop.go b/runtime/loop.go index 5cda993..64cda1f 100644 --- a/runtime/loop.go +++ b/runtime/loop.go @@ -184,6 +184,11 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { if err := recoverPending(ctx, cfg); err != nil { return Result{}, err } + // 尾部配对兜底:无论进程死在 persist(assistant) 与 commitToolIntent + // 之间的哪个窗口,补全尾部 assistant 的无结果调用,转录配对恒成立。 + if err := repairOrphanTail(cfg); err != nil { + return Result{}, err + } if cfg.Session != nil { loaded, err := cfg.Session.Load() if err != nil { @@ -337,8 +342,8 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { if decision.Continue && stopContinues < maxStopContinuations { stopContinues++ if decision.Replacement != "" && len(messages) > 0 { - messages[len(messages)-1].Content = decision.Replacement - last.Content = decision.Replacement + // 替换文只以 user 消息追加(N3 WP1/N6:不再改写内存里的 + // assistant——内存与转录保持一致;模型经该消息看到更正)。 note := llm.Message{Role: llm.RoleUser, Content: decision.Replacement} if err := persist(cfg, note); err != nil { return Result{}, err @@ -706,6 +711,10 @@ func persistedProjection(cfg Config, messages []llm.Message) []llm.Message { const unknownSideEffect = "副作用未知,未重试" +// crashInterrupted 是尾部兜底配对的正文(进程死在 persist(assistant) 与 +// commitToolIntent 之间的窗口时使用)。 +const crashInterrupted = "进程中断,该调用未执行" + func recoverPending(ctx context.Context, cfg Config) error { if cfg.Session == nil { return nil @@ -735,6 +744,56 @@ func recoverPending(ctx context.Context, cfg Config) error { return nil } +// repairOrphanTail 兜底配对:找到转录里最后一个带 tool_calls 的 assistant, +// 凡没有对应 tool 结果的调用,补一条「进程中断,该调用未执行」的 IsError +// 结果并落盘。与意图日志正交(按 CallID 判重,已配对的跳过;意图路径 +// 已处理的执行中死亡不受影响)。历史深处、其后已有新消息的孤立调用不在 +// 此处修补(追加只落在尾部,位置语义不破)——那种形态由毒化轮转覆盖。 +func repairOrphanTail(cfg Config) error { + if cfg.Session == nil { + return nil + } + msgs, err := cfg.Session.Load() + if err != nil { + return err + } + tail := -1 + for i := len(msgs) - 1; i >= 0; i-- { + m := msgs[i] + if m.Role == llm.RoleAssistant && len(m.ToolCalls) > 0 { + tail = i + break + } + if m.Role == llm.RoleTool { + continue // 尾部连续 tool 结果属于同一条 assistant,继续上溯 + } + break + } + if tail < 0 { + return nil + } + results := map[string]bool{} + for _, m := range msgs { + if m.Role == llm.RoleTool && m.ToolCallID != "" { + results[m.ToolCallID] = true + } + } + for _, call := range msgs[tail].ToolCalls { + if results[call.ID] { + continue + } + pair := llm.Message{ + Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, + Content: crashInterrupted, IsError: true, + } + if err := persist(cfg, pair); err != nil { + return err + } + results[call.ID] = true + } + return nil +} + func commitToolIntent(cfg Config, call llm.ToolCall) error { if cfg.Session == nil { return nil diff --git a/runtime/loop_harness_test.go b/runtime/loop_harness_test.go index 3683693..a4dc152 100644 --- a/runtime/loop_harness_test.go +++ b/runtime/loop_harness_test.go @@ -42,20 +42,19 @@ func TestLoopStopContinuesUntilCap(t *testing.T) { if res.Err != nil || res.Notice != "到顶了" { t.Fatalf("notice %q err %v", res.Notice, res.Err) } - replaced, forged := 0, 0 + // N6(N3 WP1):assistant 不再被改写——替换文只以 user 消息出现, + // 内存与转录一致。 + forged, notes := 0, 0 for _, m := range res.Messages { - if m.Role != llm.RoleAssistant { - continue - } - if m.Content == "已作废" { - replaced++ - } - if m.Content == "forged-claim" { + if m.Role == llm.RoleAssistant && m.Content == "forged-claim" { forged++ } + if m.Role == llm.RoleUser && m.Content == "已作废" { + notes++ + } } - if replaced != 3 || forged != 1 { - t.Fatalf("replaced=%d forged=%d transcript=%s", replaced, forged, transcriptText(res.Messages)) + if forged != 4 || notes != 3 { + t.Fatalf("forged=%d notes=%d transcript=%s", forged, notes, transcriptText(res.Messages)) } if strings.Count(transcriptText(res.Messages), "再试") != 3 { t.Fatalf("reasons = %s", transcriptText(res.Messages))