Merge pull request 'Repair orphan tool-call tails on resume and stop mutating memory on Stop.Replacement (N3 WP1)' (#30) from feature/n3-runstate-wp1 into main
Repair orphan tool-call tails on resume and stop mutating memory on Stop.Replacement (N3 WP1)
This commit was merged in pull request #30.
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+61
-2
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user