@@ -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 {
@@ -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")
+}