From 1528c82f2e2d03c1ef5eaa56edd469f0f8309baf Mon Sep 17 00:00:00 2001 From: Kim Yann Date: Fri, 17 Apr 2026 02:34:01 +0800 Subject: [PATCH] fix: inject synthetic tool_result for orphaned tool_use on session resume (#2622) When a session is interrupted mid-tool-call (Ctrl-C, network timeout), the assistant message contains a tool_use block but no corresponding tool_result is ever recorded. On session resume, the Anthropic API rejects the conversation with invalid_request_error, permanently locking the session. The existing filterOrphanedToolResults handles the inverse case (tool_result without a matching tool_use). This commit adds the symmetric fix: during preparePrompt, detect tool_use IDs that have no matching tool_result and inject a synthetic error tool_result so the API accepts the history and the session can continue. Co-authored-by: Christian Rocha Co-authored-by: Andrey Nering --- internal/agent/agent.go | 55 +++++++++++++- internal/agent/agent_test.go | 141 +++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 4 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index f25cbdc7849c9f9f3d55e34206faaca82834960c..b3249e501f8e4a31e0199bc87014d8f6aa69979f 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -772,13 +772,22 @@ If not, please feel free to ignore. Again do not mention this message to the use ), )) } - // Collect all tool call IDs present in assistant messages. + // Collect all tool call IDs present in assistant messages and all tool + // result IDs present in tool messages. This lets us detect both orphaned + // tool results (result without a call) and orphaned tool calls (call + // without a result). knownToolCallIDs := make(map[string]struct{}) + knownToolResultIDs := make(map[string]struct{}) for _, m := range msgs { - if m.Role == message.Assistant { + switch m.Role { + case message.Assistant: for _, tc := range m.ToolCalls() { knownToolCallIDs[tc.ID] = struct{}{} } + case message.Tool: + for _, tr := range m.ToolResults() { + knownToolResultIDs[tr.ToolCallID] = struct{}{} + } } } @@ -786,8 +795,7 @@ If not, please feel free to ignore. Again do not mention this message to the use if len(m.Parts) == 0 { continue } - // Assistant message without content or tool calls (cancelled before it - // returned anything). + // Assistant message without content or tool calls (cancelled before it returned anything). if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" { continue } @@ -798,6 +806,12 @@ If not, please feel free to ignore. Again do not mention this message to the use continue } history = append(history, m.ToAIMessage()...) + + if m.Role == message.Assistant { + if msg, ok := syntheticToolResultsForOrphanedCalls(m, knownToolResultIDs); ok { + history = append(history, msg) + } + } } var files []fantasy.FilePart @@ -848,6 +862,39 @@ func filterOrphanedToolResults(m message.Message, knownToolCallIDs map[string]st return msg, true } +// syntheticToolResultsForOrphanedCalls returns a tool message containing +// synthetic tool results for any tool calls in the assistant message that +// have no matching result in knownToolResultIDs. LLM APIs require every +// tool_use to be immediately followed by a tool_result; an interrupted +// session can leave orphaned tool_use blocks that permanently lock the +// conversation. Returns the message and true if any synthetic results were +// produced. +func syntheticToolResultsForOrphanedCalls(m message.Message, knownToolResultIDs map[string]struct{}) (fantasy.Message, bool) { + var syntheticParts []fantasy.MessagePart + for _, tc := range m.ToolCalls() { + if _, hasResult := knownToolResultIDs[tc.ID]; hasResult { + continue + } + slog.Warn("Injecting synthetic tool result for orphaned tool call", + "tool_call_id", tc.ID, + "tool_name", tc.Name, + ) + syntheticParts = append(syntheticParts, fantasy.ToolResultPart{ + ToolCallID: tc.ID, + Output: fantasy.ToolResultOutputContentError{ + Error: errors.New("tool call was interrupted and did not produce a result, you may retry this call if the result is still needed"), + }, + }) + } + if len(syntheticParts) == 0 { + return fantasy.Message{}, false + } + return fantasy.Message{ + Role: fantasy.MessageRoleTool, + Content: syntheticParts, + }, true +} + func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) { msgs, err := a.messages.List(ctx, session.ID) if err != nil { diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 09ae8c8291b370a19e449f6416243f0b7e65eff6..6c04938e4fe6f3a524deac9c93b2eedd544e66ab 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -651,3 +651,144 @@ func BenchmarkBuildSummaryPrompt(b *testing.B) { }) } } + +func TestPreparePrompt_OrphanedToolUse(t *testing.T) { + t.Parallel() + env := testEnv(t) + sa := testSessionAgent(env, nil, nil, "test prompt") + agent := sa.(*sessionAgent) + + ctx := t.Context() + sess, err := env.sessions.Create(ctx, "test") + require.NoError(t, err) + + // Create a user message. + _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{ + Role: message.User, + Parts: []message.ContentPart{ + message.TextContent{Text: "hello"}, + }, + }) + require.NoError(t, err) + + // Create an assistant message with a tool call but no tool result — + // this simulates a cancelled/interrupted agent tool call. + _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{ + Role: message.Assistant, + Parts: []message.ContentPart{ + message.TextContent{Text: "let me check"}, + message.ToolCall{ + ID: "call_orphaned_1", + Name: "agent", + Input: `{"prompt":"do something"}`, + Finished: true, + }, + }, + }) + require.NoError(t, err) + + // Create the next user message (the one that interrupted the tool call). + _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{ + Role: message.User, + Parts: []message.ContentPart{ + message.TextContent{Text: "Fix #2"}, + }, + }) + require.NoError(t, err) + + msgs, err := env.messages.List(ctx, sess.ID) + require.NoError(t, err) + + history, _ := agent.preparePrompt(msgs) + + // The history must contain a synthetic tool result for the orphaned call. + found := false + for _, msg := range history { + if msg.Role != fantasy.MessageRoleTool { + continue + } + for _, part := range msg.Content { + if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok { + if tr.ToolCallID == "call_orphaned_1" { + found = true + _, isError := tr.Output.(fantasy.ToolResultOutputContentError) + require.True(t, isError, "orphaned tool result should be an error") + } + } + } + } + require.True(t, found, "expected synthetic tool result for orphaned tool call") +} + +func TestPreparePrompt_OrphanedToolUseMixed(t *testing.T) { + t.Parallel() + env := testEnv(t) + sa := testSessionAgent(env, nil, nil, "test prompt") + agent := sa.(*sessionAgent) + + ctx := t.Context() + sess, err := env.sessions.Create(ctx, "test") + require.NoError(t, err) + + _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{ + Role: message.User, + Parts: []message.ContentPart{ + message.TextContent{Text: "hello"}, + }, + }) + require.NoError(t, err) + + // Assistant with 2 tool calls: one has a result, one is orphaned. + _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{ + Role: message.Assistant, + Parts: []message.ContentPart{ + message.ToolCall{ + ID: "call_ok", + Name: "view", + Input: `{"path":"/foo"}`, + Finished: true, + }, + message.ToolCall{ + ID: "call_orphaned", + Name: "agent", + Input: `{"prompt":"search"}`, + Finished: true, + }, + }, + }) + require.NoError(t, err) + + // Only one tool result — for call_ok. + _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{ + Role: message.Tool, + Parts: []message.ContentPart{ + message.ToolResult{ + ToolCallID: "call_ok", + Name: "view", + Content: "file contents", + }, + }, + }) + require.NoError(t, err) + + msgs, err := env.messages.List(ctx, sess.ID) + require.NoError(t, err) + + history, _ := agent.preparePrompt(msgs) + + // Should have a synthetic result only for the orphaned call. + var syntheticCount int + for _, msg := range history { + if msg.Role != fantasy.MessageRoleTool { + continue + } + for _, part := range msg.Content { + if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok { + if tr.ToolCallID == "call_orphaned" { + syntheticCount++ + } + } + } + } + require.Equal(t, 1, syntheticCount, "expected exactly one synthetic result for the orphaned call") +}