on main help sign in
fix: inject synthetic tool_result for orphaned tool_use on session resume (#2622)
1528c82Kim Yann
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
772 ),
772 ),
773 ))
773 ))
774 }
774 }
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).
776 knownToolCallIDs := make(map[string]struct{})
779 knownToolCallIDs := make(map[string]struct{})
780+ knownToolResultIDs := make(map[string]struct{})
777 for _, m := range msgs {
781 for _, m := range msgs {
782+ switch m.Role {
778− if m.Role == message.Assistant {
783+ case message.Assistant:
779 for _, tc := range m.ToolCalls() {
784 for _, tc := range m.ToolCalls() {
780 knownToolCallIDs[tc.ID] = struct{}{}
785 knownToolCallIDs[tc.ID] = struct{}{}
781 }
786 }
787+ case message.Tool:
788+ for _, tr := range m.ToolResults() {
789+ knownToolResultIDs[tr.ToolCallID] = struct{}{}
790+ }
782 }
791 }
783 }
792 }
784
793
@@ -786,8 +795,7 @@ If not, please feel free to ignore. Again do not mention this message to the use
786 if len(m.Parts) == 0 {
795 if len(m.Parts) == 0 {
787 continue
796 continue
788 }
797 }
789− // Assistant message without content or tool calls (cancelled before it
798+ // Assistant message without content or tool calls (cancelled before it returned anything).
790− // returned anything).
791 if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
799 if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
792 continue
800 continue
793 }
801 }
@@ -798,6 +806,12 @@ If not, please feel free to ignore. Again do not mention this message to the use
798 continue
806 continue
799 }
807 }
800 history = append(history, m.ToAIMessage()...)
808 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+ }
801 }
815 }
802
816
803 var files []fantasy.FilePart
817 var files []fantasy.FilePart
@@ -848,6 +862,39 @@ func filterOrphanedToolResults(m message.Message, knownToolCallIDs map[string]st
848 return msg, true
862 return msg, true
849}
863}
850
864
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+
851func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
898func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
852 msgs, err := a.messages.List(ctx, session.ID)
899 msgs, err := a.messages.List(ctx, session.ID)
853 if err != nil {
900 if err != nil {
internal/agent/agent_test.go+141
@@ -651,3 +651,144 @@ func BenchmarkBuildSummaryPrompt(b *testing.B) {
651 })
651 })
652 }
652 }
653}
653}
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+}