| 1 | package provider |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | // RepairHistoryForReplay turns unusable historical tool batches into ordinary |
| 9 | // records, never executable calls. Unlike replacing unknown arguments with {}, |
| 10 | // this preserves uncertainty about side effects and keeps recorded results. |
| 11 | // Use only for host history, before extensions and the final transcript gate. |
| 12 | // Canonical messages are not mutated; healthy wire bytes remain unchanged. |
| 13 | func RepairHistoryForReplay(msgs []Message) []Message { |
| 14 | out := RepairRejectedArguments(ProjectionMessages(msgs)) |
| 15 | for i := 0; i < len(out); { |
| 16 | m := out[i] |
| 17 | if len(m.ToolCalls) == 0 { |
| 18 | // Unpaired results are still observations, even when their call was |
| 19 | // lost at an interrupted boundary. Do not silently drop the facts. |
| 20 | if m.Role == RoleTool { |
| 21 | out[i] = archivedToolResult(m) |
| 22 | } |
| 23 | i++ |
| 24 | continue |
| 25 | } |
| 26 | end := i + 1 |
| 27 | for end < len(out) && out[end].Role == RoleTool { |
| 28 | end++ |
| 29 | } |
| 30 | if !replayableToolBatch(m) { |
| 31 | out[i] = archivedToolCalls(m) |
| 32 | for j := i + 1; j < end; j++ { |
| 33 | out[j] = archivedToolResult(out[j]) |
| 34 | } |
| 35 | } |
| 36 | i = end |
| 37 | } |
| 38 | return out |
| 39 | } |
| 40 | |
| 41 | func replayableToolBatch(m Message) bool { |
| 42 | if m.Role != RoleAssistant { |
| 43 | return false |
| 44 | } |
| 45 | seen := make(map[string]bool, len(m.ToolCalls)) |
| 46 | for _, call := range m.ToolCalls { |
| 47 | if strings.TrimSpace(call.Name) == "" { |
| 48 | return false |
| 49 | } |
| 50 | // Empty IDs retain the existing positional compatibility path. |
| 51 | if call.ID != "" { |
| 52 | if strings.TrimSpace(call.ID) == "" || seen[call.ID] { |
| 53 | return false |
| 54 | } |
| 55 | seen[call.ID] = true |
| 56 | } |
| 57 | var args map[string]json.RawMessage |
| 58 | if json.Unmarshal([]byte(call.Arguments), &args) != nil || args == nil { |
| 59 | return false |
| 60 | } |
| 61 | } |
| 62 | return true |
| 63 | } |
| 64 | |
| 65 | func archivedToolCalls(m Message) Message { |
| 66 | type record struct { |
| 67 | ID string `json:"id"` |
| 68 | Name string `json:"name"` |
| 69 | Arguments string `json:"original_arguments"` |
| 70 | } |
| 71 | calls := make([]record, 0, len(m.ToolCalls)) |
| 72 | for _, call := range m.ToolCalls { |
| 73 | calls = append(calls, record{call.ID, call.Name, call.Arguments}) |
| 74 | } |
| 75 | data, _ := json.Marshal(calls) // strings only; no local recovery metadata |
| 76 | return archivedMessage(m, "[Historical tool record: the original call format cannot be replayed. These are archived proposals, not new tool calls. Execution is unconfirmed unless the recorded results establish it. Do not repeat actions solely because this record was recovered; verify their effects first.]\n"+string(data)) |
| 77 | } |
| 78 | |
| 79 | func archivedToolResult(m Message) Message { |
| 80 | data, _ := json.Marshal(struct { |
| 81 | CallID string `json:"call_id"` |
| 82 | Name string `json:"name"` |
| 83 | State ToolRunState `json:"recorded_execution_state,omitempty"` |
| 84 | }{m.ToolCallID, m.Name, m.ToolRunState}) |
| 85 | // Content is the existing provider-visible observation, never RawContent. |
| 86 | return archivedMessage(m, "[Historical tool observation; pairing may be incomplete. This record does not request a new execution.]\n"+string(data)) |
| 87 | } |
| 88 | |
| 89 | func archivedMessage(m Message, note string) Message { |
| 90 | content := note |
| 91 | if m.Content != "" { |
| 92 | data, _ := json.Marshal(struct { |
| 93 | Content string `json:"original_message_content"` |
| 94 | }{m.Content}) |
| 95 | content += "\n" + string(data) |
| 96 | } |
| 97 | // Drop native tool replay metadata. Host-authored user data preserves images |
| 98 | // and avoids assistant prefill, which thinking providers may reject. |
| 99 | return Message{ID: m.ID, Role: RoleUser, Origin: MessageOriginHost, Content: content, Images: m.Images, ImageInputs: m.ImageInputs} |
| 100 | } |
| 101 |