| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "html" |
| 6 | "slices" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/provider" |
| 10 | ) |
| 11 | |
| 12 | const interruptedRecoveryTag = "interrupted-turn-recovery" |
| 13 | |
| 14 | const ( |
| 15 | maxRecoveryTools = 24 |
| 16 | maxRecoveryFiles = 8 |
| 17 | maxRecoveryValue = 240 |
| 18 | ) |
| 19 | |
| 20 | // pendingInterruptedRecovery returns the newest unconsumed recovery handoff. |
| 21 | // A later real user turn consumes older handoffs implicitly, so the persisted |
| 22 | // LocalOnly record never needs an in-place mutation that could churn history. |
| 23 | func (a *Agent) transcriptInterruptedRecovery() *provider.InterruptedTurnRecovery { |
| 24 | if a == nil || a.sess.conversation == nil { |
| 25 | return nil |
| 26 | } |
| 27 | msgs := a.sess.conversation.Snapshot() |
| 28 | for _, v := range slices.Backward(msgs) { |
| 29 | m := v |
| 30 | if m.LocalOnly && m.InterruptedTurn != nil && m.InterruptedTurn.Pending { |
| 31 | copy := *m.InterruptedTurn |
| 32 | if copy.FailureDiagnostic != nil { |
| 33 | diagnostic := *copy.FailureDiagnostic |
| 34 | copy.FailureDiagnostic = &diagnostic |
| 35 | } |
| 36 | copy.WriteChecks = append([]provider.WriteRecoveryCheck(nil), copy.WriteChecks...) |
| 37 | copy.SatisfiedWrites = append([]provider.InterruptedToolSummary(nil), copy.SatisfiedWrites...) |
| 38 | copy.CompletedTools = append([]provider.InterruptedToolSummary(nil), copy.CompletedTools...) |
| 39 | copy.InterruptedTools = append([]string(nil), copy.InterruptedTools...) |
| 40 | copy.NotStartedTools = append([]provider.InterruptedToolSummary(nil), copy.NotStartedTools...) |
| 41 | copy.UnknownTools = append([]provider.InterruptedToolSummary(nil), copy.UnknownTools...) |
| 42 | return © |
| 43 | } |
| 44 | if IsUserAuthoredTurnMessage(m) { |
| 45 | return nil |
| 46 | } |
| 47 | } |
| 48 | return nil |
| 49 | } |
| 50 | |
| 51 | // interruptedRecoveryBlock is appended only at the mutable user-message tail. |
| 52 | // It contains no raw tool arguments, results, assistant text, or reasoning. |
| 53 | func interruptedRecoveryBlock(r *provider.InterruptedTurnRecovery) string { |
| 54 | if r == nil { |
| 55 | return "" |
| 56 | } |
| 57 | var b strings.Builder |
| 58 | fmt.Fprintf(&b, "<%s>\n", interruptedRecoveryTag) |
| 59 | if len(r.UserConfirmedTools) > 0 { |
| 60 | b.WriteString("The previous turn was interrupted. Preserve the stated provenance: user-confirmed effects are attestations, not tool results.\n") |
| 61 | } else { |
| 62 | b.WriteString("The previous turn was interrupted. Treat these as host-verified recovery facts, not as a new task.\n") |
| 63 | } |
| 64 | if len(r.CompletedTools) == 0 { |
| 65 | b.WriteString("completed_tools: none\n") |
| 66 | } else { |
| 67 | b.WriteString("completed_tools:\n") |
| 68 | for i, tool := range r.CompletedTools { |
| 69 | if i >= maxRecoveryTools { |
| 70 | fmt.Fprintf(&b, "- ... %d additional completed tool pair(s) omitted\n", len(r.CompletedTools)-i) |
| 71 | break |
| 72 | } |
| 73 | fmt.Fprintf(&b, "- %s", html.EscapeString(strings.TrimSpace(tool.Name))) |
| 74 | if len(tool.Files) > 0 { |
| 75 | files := tool.Files |
| 76 | if len(files) > maxRecoveryFiles { |
| 77 | files = files[:maxRecoveryFiles] |
| 78 | } |
| 79 | clipped := make([]string, 0, len(files)) |
| 80 | for _, file := range files { |
| 81 | clipped = append(clipped, html.EscapeString(clipRecoveryValue(file))) |
| 82 | } |
| 83 | fmt.Fprintf(&b, " files=%s", strings.Join(clipped, ",")) |
| 84 | } |
| 85 | if tool.Added != 0 || tool.Removed != 0 { |
| 86 | fmt.Fprintf(&b, " diff=+%d/-%d", tool.Added, tool.Removed) |
| 87 | } |
| 88 | b.WriteByte('\n') |
| 89 | } |
| 90 | } |
| 91 | if len(r.InterruptedTools) == 0 { |
| 92 | b.WriteString("interrupted_tools: none\n") |
| 93 | } else { |
| 94 | b.WriteString("interrupted_tools: ") |
| 95 | for i, name := range r.InterruptedTools { |
| 96 | if i >= maxRecoveryTools { |
| 97 | fmt.Fprintf(&b, ", ... %d additional call(s) omitted", len(r.InterruptedTools)-i) |
| 98 | break |
| 99 | } |
| 100 | if i > 0 { |
| 101 | b.WriteString(", ") |
| 102 | } |
| 103 | b.WriteString(html.EscapeString(strings.TrimSpace(name))) |
| 104 | } |
| 105 | b.WriteByte('\n') |
| 106 | } |
| 107 | writeRecoveryChecks(&b, r.WriteChecks) |
| 108 | writeRecoveryCalls(&b, "write_postconditions_satisfied_do_not_repeat", r.SatisfiedWrites) |
| 109 | writeRecoveryCalls(&b, "not_started_tools", r.NotStartedTools) |
| 110 | writeRecoveryCalls(&b, "outcome_unknown_tools", r.UnknownTools) |
| 111 | writeRecoveryCalls(&b, "failed_tools", r.FailedTools) |
| 112 | writeRecoveryCalls(&b, "user_confirmed_effects_do_not_repeat", r.UserConfirmedTools) |
| 113 | if r.DroppedPartialText || r.DroppedPartialReasoning { |
| 114 | b.WriteString("unsafe_partial_output: excluded from model context") |
| 115 | if r.DroppedPartialText && r.DroppedPartialReasoning { |
| 116 | b.WriteString(" (assistant text and reasoning)\n") |
| 117 | } else if r.DroppedPartialReasoning { |
| 118 | b.WriteString(" (reasoning)\n") |
| 119 | } else { |
| 120 | b.WriteString(" (assistant text)\n") |
| 121 | } |
| 122 | } |
| 123 | b.WriteString("Use these facts when deciding the next action. Read-only or idempotent calls may be retried when useful. For outcome-unknown calls, inspect workspace or external state before retrying operations with side effects, and ask the user when the safe action cannot be inferred. Calls marked not_started may be planned again with complete arguments.\n") |
| 124 | fmt.Fprintf(&b, "</%s>", interruptedRecoveryTag) |
| 125 | return b.String() |
| 126 | } |
| 127 | |
| 128 | func withInterruptedRecovery(input string, r *provider.InterruptedTurnRecovery) string { |
| 129 | block := interruptedRecoveryBlock(r) |
| 130 | if block == "" { |
| 131 | return input |
| 132 | } |
| 133 | return block + "\n\n" + input |
| 134 | } |
| 135 | |
| 136 | func clipRecoveryValue(value string) string { |
| 137 | value = strings.TrimSpace(value) |
| 138 | runes := []rune(value) |
| 139 | if len(runes) <= maxRecoveryValue { |
| 140 | return value |
| 141 | } |
| 142 | return string(runes[:maxRecoveryValue]) + "…" |
| 143 | } |
| 144 | |
| 145 | func writeRecoveryCalls(b *strings.Builder, label string, calls []provider.InterruptedToolSummary) { |
| 146 | if len(calls) == 0 { |
| 147 | return |
| 148 | } |
| 149 | fmt.Fprintf(b, "%s:\n", label) |
| 150 | for i, call := range calls { |
| 151 | if i >= maxRecoveryTools { |
| 152 | fmt.Fprintf(b, "- ... %d omitted\n", len(calls)-i) |
| 153 | break |
| 154 | } |
| 155 | fmt.Fprintf(b, "- %s id=%s\n", html.EscapeString(clipRecoveryValue(call.Name)), html.EscapeString(clipRecoveryValue(call.ID))) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func writeRecoveryChecks(b *strings.Builder, checks []provider.WriteRecoveryCheck) { |
| 160 | for i, check := range checks { |
| 161 | if i >= maxRecoveryTools*maxRecoveryFiles { |
| 162 | break |
| 163 | } |
| 164 | fmt.Fprintf(b, "write_postcondition: id=%s path=%s state=%s\n", html.EscapeString(clipRecoveryValue(check.CallID)), html.EscapeString(clipRecoveryValue(check.Path)), html.EscapeString(clipRecoveryValue(check.State))) |
| 165 | } |
| 166 | } |
| 167 |