Repair orphan tool-call tails on resume and stop mutating memory on Stop.Replacement (N3 WP1) #30

Merged
weichen merged 1 commits from feature/n3-runstate-wp1 into main 2026-10-07 15:03:53 +00:00
3 changed files with 224 additions and 12 deletions
+154
View File
@@ -208,3 +208,157 @@ func TestSpansUseAgreedNamesWithoutPrompt(t *testing.T) {
t.Fatal("prompt leaked into span attrs") 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))
}
}
+61 -2
View File
@@ -184,6 +184,11 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
if err := recoverPending(ctx, cfg); err != nil { if err := recoverPending(ctx, cfg); err != nil {
return Result{}, err return Result{}, err
} }
// 尾部配对兜底:无论进程死在 persist(assistant) 与 commitToolIntent
// 之间的哪个窗口,补全尾部 assistant 的无结果调用,转录配对恒成立。
if err := repairOrphanTail(cfg); err != nil {
return Result{}, err
}
if cfg.Session != nil { if cfg.Session != nil {
loaded, err := cfg.Session.Load() loaded, err := cfg.Session.Load()
if err != nil { if err != nil {
@@ -337,8 +342,8 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
if decision.Continue && stopContinues < maxStopContinuations { if decision.Continue && stopContinues < maxStopContinuations {
stopContinues++ stopContinues++
if decision.Replacement != "" && len(messages) > 0 { if decision.Replacement != "" && len(messages) > 0 {
messages[len(messages)-1].Content = decision.Replacement // 替换文只以 user 消息追加(N3 WP1/N6:不再改写内存里的
last.Content = decision.Replacement // assistant——内存与转录保持一致;模型经该消息看到更正)。
note := llm.Message{Role: llm.RoleUser, Content: decision.Replacement} note := llm.Message{Role: llm.RoleUser, Content: decision.Replacement}
if err := persist(cfg, note); err != nil { if err := persist(cfg, note); err != nil {
return Result{}, err return Result{}, err
@@ -706,6 +711,10 @@ func persistedProjection(cfg Config, messages []llm.Message) []llm.Message {
const unknownSideEffect = "副作用未知,未重试" const unknownSideEffect = "副作用未知,未重试"
// crashInterrupted 是尾部兜底配对的正文(进程死在 persist(assistant) 与
// commitToolIntent 之间的窗口时使用)。
const crashInterrupted = "进程中断,该调用未执行"
func recoverPending(ctx context.Context, cfg Config) error { func recoverPending(ctx context.Context, cfg Config) error {
if cfg.Session == nil { if cfg.Session == nil {
return nil return nil
@@ -735,6 +744,56 @@ func recoverPending(ctx context.Context, cfg Config) error {
return nil 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 { func commitToolIntent(cfg Config, call llm.ToolCall) error {
if cfg.Session == nil { if cfg.Session == nil {
return nil return nil
+9 -10
View File
@@ -42,20 +42,19 @@ func TestLoopStopContinuesUntilCap(t *testing.T) {
if res.Err != nil || res.Notice != "到顶了" { if res.Err != nil || res.Notice != "到顶了" {
t.Fatalf("notice %q err %v", res.Notice, res.Err) 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 { for _, m := range res.Messages {
if m.Role != llm.RoleAssistant { if m.Role == llm.RoleAssistant && m.Content == "forged-claim" {
continue
}
if m.Content == "已作废" {
replaced++
}
if m.Content == "forged-claim" {
forged++ forged++
} }
if m.Role == llm.RoleUser && m.Content == "已作废" {
notes++
}
} }
if replaced != 3 || forged != 1 { if forged != 4 || notes != 3 {
t.Fatalf("replaced=%d forged=%d transcript=%s", replaced, forged, transcriptText(res.Messages)) t.Fatalf("forged=%d notes=%d transcript=%s", forged, notes, transcriptText(res.Messages))
} }
if strings.Count(transcriptText(res.Messages), "再试") != 3 { if strings.Count(transcriptText(res.Messages), "再试") != 3 {
t.Fatalf("reasons = %s", transcriptText(res.Messages)) t.Fatalf("reasons = %s", transcriptText(res.Messages))