on main help sign in
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 <christian@rocha.is> Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>

commit1528c82f2e2d03c1ef5eaa56edd469f0f8309baf authorKim Yann <kimy@nvidia.com> dateFri 17 Apr 2026, 02:34 (6 months ago) parent1e0e816
2 files changed+192−4
internal/agent/agent.go+51−4 internal/agent/agent_test.go+141
internal/agent/agent.go+51−4
@@ -772,13 +772,22 @@ If not, please feel free to ignore. Again do not mention this message to the use
772772 ),
773773 ))
774774 }
775− // Collect all tool call IDs present in assistant messages.
775+ // Collect all tool call IDs present in assistant messages and all tool
776+ // result IDs present in tool messages. This lets us detect both orphaned
777+ // tool results (result without a call) and orphaned tool calls (call
778+ // without a result).
776779 knownToolCallIDs := make(map[string]struct{})
780+ knownToolResultIDs := make(map[string]struct{})
777781 for _, m := range msgs {
778− if m.Role == message.Assistant {
782+ switch m.Role {
783+ case message.Assistant:
779784 for _, tc := range m.ToolCalls() {
780785 knownToolCallIDs[tc.ID] = struct{}{}
781786 }
787+ case message.Tool:
788+ for _, tr := range m.ToolResults() {
789+ knownToolResultIDs[tr.ToolCallID] = struct{}{}
790+ }
782791 }
783792 }
784793
@@ -786,8 +795,7 @@ If not, please feel free to ignore. Again do not mention this message to the use
786795 if len(m.Parts) == 0 {
787796 continue
788797 }
789− // Assistant message without content or tool calls (cancelled before it
790− // returned anything).
798+ // Assistant message without content or tool calls (cancelled before it returned anything).
791799 if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
792800 continue
793801 }
@@ -798,6 +806,12 @@ If not, please feel free to ignore. Again do not mention this message to the use
798806 continue
799807 }
800808 history = append(history, m.ToAIMessage()...)
809+
810+ if m.Role == message.Assistant {
811+ if msg, ok := syntheticToolResultsForOrphanedCalls(m, knownToolResultIDs); ok {
812+ history = append(history, msg)
813+ }
814+ }
801815 }
802816
803817 var files []fantasy.FilePart
@@ -848,6 +862,39 @@ func filterOrphanedToolResults(m message.Message, knownToolCallIDs map[string]st
848862 return msg, true
849863}
850864
865+// syntheticToolResultsForOrphanedCalls returns a tool message containing
866+// synthetic tool results for any tool calls in the assistant message that
867+// have no matching result in knownToolResultIDs. LLM APIs require every
868+// tool_use to be immediately followed by a tool_result; an interrupted
869+// session can leave orphaned tool_use blocks that permanently lock the
870+// conversation. Returns the message and true if any synthetic results were
871+// produced.
872+func syntheticToolResultsForOrphanedCalls(m message.Message, knownToolResultIDs map[string]struct{}) (fantasy.Message, bool) {
873+ var syntheticParts []fantasy.MessagePart
874+ for _, tc := range m.ToolCalls() {
875+ if _, hasResult := knownToolResultIDs[tc.ID]; hasResult {
876+ continue
877+ }
878+ slog.Warn("Injecting synthetic tool result for orphaned tool call",
879+ "tool_call_id", tc.ID,
880+ "tool_name", tc.Name,
881+ )
882+ syntheticParts = append(syntheticParts, fantasy.ToolResultPart{
883+ ToolCallID: tc.ID,
884+ Output: fantasy.ToolResultOutputContentError{
885+ Error: errors.New("tool call was interrupted and did not produce a result, you may retry this call if the result is still needed"),
886+ },
887+ })
888+ }
889+ if len(syntheticParts) == 0 {
890+ return fantasy.Message{}, false
891+ }
892+ return fantasy.Message{
893+ Role: fantasy.MessageRoleTool,
894+ Content: syntheticParts,
895+ }, true
896+}
897+
851898func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
852899 msgs, err := a.messages.List(ctx, session.ID)
853900 if err != nil {
internal/agent/agent_test.go+141
@@ -651,3 +651,144 @@ func BenchmarkBuildSummaryPrompt(b *testing.B) {
651651 })
652652 }
653653}
654+
655+func TestPreparePrompt_OrphanedToolUse(t *testing.T) {
656+ t.Parallel()
657+ env := testEnv(t)
658+ sa := testSessionAgent(env, nil, nil, "test prompt")
659+ agent := sa.(*sessionAgent)
660+
661+ ctx := t.Context()
662+ sess, err := env.sessions.Create(ctx, "test")
663+ require.NoError(t, err)
664+
665+ // Create a user message.
666+ _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
667+ Role: message.User,
668+ Parts: []message.ContentPart{
669+ message.TextContent{Text: "hello"},
670+ },
671+ })
672+ require.NoError(t, err)
673+
674+ // Create an assistant message with a tool call but no tool result —
675+ // this simulates a cancelled/interrupted agent tool call.
676+ _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
677+ Role: message.Assistant,
678+ Parts: []message.ContentPart{
679+ message.TextContent{Text: "let me check"},
680+ message.ToolCall{
681+ ID: "call_orphaned_1",
682+ Name: "agent",
683+ Input: `{"prompt":"do something"}`,
684+ Finished: true,
685+ },
686+ },
687+ })
688+ require.NoError(t, err)
689+
690+ // Create the next user message (the one that interrupted the tool call).
691+ _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
692+ Role: message.User,
693+ Parts: []message.ContentPart{
694+ message.TextContent{Text: "Fix #2"},
695+ },
696+ })
697+ require.NoError(t, err)
698+
699+ msgs, err := env.messages.List(ctx, sess.ID)
700+ require.NoError(t, err)
701+
702+ history, _ := agent.preparePrompt(msgs)
703+
704+ // The history must contain a synthetic tool result for the orphaned call.
705+ found := false
706+ for _, msg := range history {
707+ if msg.Role != fantasy.MessageRoleTool {
708+ continue
709+ }
710+ for _, part := range msg.Content {
711+ if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok {
712+ if tr.ToolCallID == "call_orphaned_1" {
713+ found = true
714+ _, isError := tr.Output.(fantasy.ToolResultOutputContentError)
715+ require.True(t, isError, "orphaned tool result should be an error")
716+ }
717+ }
718+ }
719+ }
720+ require.True(t, found, "expected synthetic tool result for orphaned tool call")
721+}
722+
723+func TestPreparePrompt_OrphanedToolUseMixed(t *testing.T) {
724+ t.Parallel()
725+ env := testEnv(t)
726+ sa := testSessionAgent(env, nil, nil, "test prompt")
727+ agent := sa.(*sessionAgent)
728+
729+ ctx := t.Context()
730+ sess, err := env.sessions.Create(ctx, "test")
731+ require.NoError(t, err)
732+
733+ _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
734+ Role: message.User,
735+ Parts: []message.ContentPart{
736+ message.TextContent{Text: "hello"},
737+ },
738+ })
739+ require.NoError(t, err)
740+
741+ // Assistant with 2 tool calls: one has a result, one is orphaned.
742+ _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
743+ Role: message.Assistant,
744+ Parts: []message.ContentPart{
745+ message.ToolCall{
746+ ID: "call_ok",
747+ Name: "view",
748+ Input: `{"path":"/foo"}`,
749+ Finished: true,
750+ },
751+ message.ToolCall{
752+ ID: "call_orphaned",
753+ Name: "agent",
754+ Input: `{"prompt":"search"}`,
755+ Finished: true,
756+ },
757+ },
758+ })
759+ require.NoError(t, err)
760+
761+ // Only one tool result — for call_ok.
762+ _, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
763+ Role: message.Tool,
764+ Parts: []message.ContentPart{
765+ message.ToolResult{
766+ ToolCallID: "call_ok",
767+ Name: "view",
768+ Content: "file contents",
769+ },
770+ },
771+ })
772+ require.NoError(t, err)
773+
774+ msgs, err := env.messages.List(ctx, sess.ID)
775+ require.NoError(t, err)
776+
777+ history, _ := agent.preparePrompt(msgs)
778+
779+ // Should have a synthetic result only for the orphaned call.
780+ var syntheticCount int
781+ for _, msg := range history {
782+ if msg.Role != fantasy.MessageRoleTool {
783+ continue
784+ }
785+ for _, part := range msg.Content {
786+ if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok {
787+ if tr.ToolCallID == "call_orphaned" {
788+ syntheticCount++
789+ }
790+ }
791+ }
792+ }
793+ require.Equal(t, 1, syntheticCount, "expected exactly one synthetic result for the orphaned call")
794+}