| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | |
| 8 | "reasonix/internal/provider" |
| 9 | ) |
| 10 | |
| 11 | // ReasoningReplayFailure classifies why an assistant turn could not safely be |
| 12 | // committed to provider-visible history. |
| 13 | type ReasoningReplayFailure string |
| 14 | |
| 15 | const ( |
| 16 | ReasoningReplayMissing ReasoningReplayFailure = "missing_required_reasoning" |
| 17 | ReasoningReplayOverflow ReasoningReplayFailure = "reasoning_overflow" |
| 18 | ReasoningReplayIncomplete ReasoningReplayFailure = "incomplete_reasoning" |
| 19 | ReasoningReplayUnreplayable ReasoningReplayFailure = "unreplayable_history" |
| 20 | ) |
| 21 | |
| 22 | // ReasoningReplayError stops client tools before execution when their provider |
| 23 | // reasoning cannot be replayed. Completed work is retained as LocalOnly by the |
| 24 | // ordinary interrupted-turn recovery path. |
| 25 | type ReasoningReplayError struct { |
| 26 | Kind ReasoningReplayFailure |
| 27 | } |
| 28 | |
| 29 | func (e *ReasoningReplayError) Error() string { |
| 30 | if e != nil && e.Kind == ReasoningReplayIncomplete { |
| 31 | return "The provider ended the response with unfinished reasoning. Reasonix kept existing work and did not run the requested tools; retry to continue safely." |
| 32 | } |
| 33 | if e != nil && e.Kind == ReasoningReplayOverflow { |
| 34 | return "The provider reasoning exceeded the client safety limit, so Reasonix did not run the requested tools. Existing work was kept; retry to continue safely." |
| 35 | } |
| 36 | return "The provider repeatedly omitted reasoning required to replay this tool turn. Reasonix exhausted its safe automatic recovery and did not run the requested tools. Existing work was kept; switch provider or protocol if this continues." |
| 37 | } |
| 38 | |
| 39 | // PauseClass names the guard that deliberately ended a run, so a host can |
| 40 | // classify an outcome without reaching into the unexported pause types. |
| 41 | // Empty for ordinary provider/tool failures. |
| 42 | func PauseClass(err error) string { |
| 43 | var budgetPause *taskBudgetPause |
| 44 | if errors.As(err, &budgetPause) { |
| 45 | return "task_budget" |
| 46 | } |
| 47 | var maxSteps *maxStepsPause |
| 48 | if errors.As(err, &maxSteps) { |
| 49 | return "max_steps" |
| 50 | } |
| 51 | var readiness *FinalReadinessError |
| 52 | if errors.As(err, &readiness) { |
| 53 | return "final_readiness" |
| 54 | } |
| 55 | var recovery *RecoveryPauseError |
| 56 | if errors.As(err, &recovery) { |
| 57 | return "recovery_paused" |
| 58 | } |
| 59 | var completion *CompletionUncertainError |
| 60 | if errors.As(err, &completion) { |
| 61 | return "completion_uncertain" |
| 62 | } |
| 63 | var incompleteRead *IncompleteReadError |
| 64 | if errors.As(err, &incompleteRead) { |
| 65 | return "incomplete_read" |
| 66 | } |
| 67 | return "" |
| 68 | } |
| 69 | |
| 70 | // IncompleteReadError is a recoverable run boundary: a read_file result was |
| 71 | // only partially visible and the host refused to let the model silently treat |
| 72 | // it as complete. It carries only routing/size metadata, never file contents. |
| 73 | type IncompleteReadError struct { |
| 74 | Pause *provider.ReadPause |
| 75 | Reason string |
| 76 | Path string |
| 77 | ToolCallID string |
| 78 | ResultRef string |
| 79 | NextOffset int |
| 80 | ConsumedBytes int |
| 81 | TotalBytes int |
| 82 | } |
| 83 | |
| 84 | func (e *IncompleteReadError) Error() string { |
| 85 | if e == nil { |
| 86 | return "read_file did not complete" |
| 87 | } |
| 88 | detail := strings.TrimSpace(e.Reason) |
| 89 | if detail == "" { |
| 90 | detail = "the retained result still has unread content" |
| 91 | } |
| 92 | return "read_file did not complete safely: " + detail |
| 93 | } |
| 94 | |
| 95 | // RunPauseInfo is the stable host-facing description of a deliberate Run |
| 96 | // boundary. It keeps unexported control-flow error types private while allowing |
| 97 | // Controller to distinguish task budgets from an explicit runtime max_steps. |
| 98 | type RunPauseInfo struct { |
| 99 | Kind string |
| 100 | Limit int |
| 101 | Key string |
| 102 | HostOwned bool |
| 103 | Reason string |
| 104 | } |
| 105 | |
| 106 | // InspectRunPause unwraps a deliberate explicit run boundary. |
| 107 | func InspectRunPause(err error) (RunPauseInfo, bool) { |
| 108 | var maxSteps *maxStepsPause |
| 109 | if errors.As(err, &maxSteps) { |
| 110 | return RunPauseInfo{Kind: "max_steps", Limit: maxSteps.steps, Key: maxSteps.key}, true |
| 111 | } |
| 112 | var budget *taskBudgetPause |
| 113 | if errors.As(err, &budget) { |
| 114 | return RunPauseInfo{Kind: "task_budget", Key: budget.axis, HostOwned: true, Reason: budget.detail}, true |
| 115 | } |
| 116 | var incompleteRead *IncompleteReadError |
| 117 | if errors.As(err, &incompleteRead) { |
| 118 | return RunPauseInfo{Kind: "incomplete_read", HostOwned: true, Reason: incompleteRead.Reason}, true |
| 119 | } |
| 120 | return RunPauseInfo{}, false |
| 121 | } |
| 122 | |
| 123 | // ReadinessContinuationClass is retained for compatibility with hosts that |
| 124 | // inspect FinalReadinessError. Ordinary Standard/Delivery turns never use it |
| 125 | // to schedule another model request; only Goal/approved-Plan orchestration may |
| 126 | // interpret the advisory class after the visible turn has ended. |
| 127 | type ReadinessContinuationClass string |
| 128 | |
| 129 | const ( |
| 130 | // ReadinessContinuationNone is also the zero value so older callers that |
| 131 | // construct FinalReadinessError directly never opt into another model turn. |
| 132 | ReadinessContinuationNone ReadinessContinuationClass = "" |
| 133 | // ReadinessContinuationGeneric covers ordinary post-write verification and |
| 134 | // review gaps for Goal/Plan diagnostics. |
| 135 | ReadinessContinuationGeneric ReadinessContinuationClass = "generic" |
| 136 | // ReadinessContinuationHighConfidence covers exact or strict, safely |
| 137 | // actionable readiness duties for Goal/Plan diagnostics. |
| 138 | ReadinessContinuationHighConfidence ReadinessContinuationClass = "high_confidence" |
| 139 | ) |
| 140 | |
| 141 | // FinalReadinessError reports that the model exhausted its recovery attempts |
| 142 | // before satisfying the host-observed delivery checks. |
| 143 | type FinalReadinessError struct { |
| 144 | Attempts int |
| 145 | Reason string |
| 146 | Missing []string |
| 147 | ContinuationClass ReadinessContinuationClass |
| 148 | ProgressKey string |
| 149 | // Operations names the concrete changes the host could not settle, so the |
| 150 | // report points at a real change with a real next action instead of a |
| 151 | // category the user has to map back onto their work themselves. |
| 152 | Operations []ReadinessOperationGap |
| 153 | } |
| 154 | |
| 155 | // ReadinessOperationGap is one unsettled host-observed change in a readiness |
| 156 | // report. Action is the closed-set next step, never prose. |
| 157 | type ReadinessOperationGap struct { |
| 158 | OperationID string `json:"operation_id"` |
| 159 | Paths []string `json:"paths,omitempty"` |
| 160 | State string `json:"state"` |
| 161 | Action string `json:"action"` |
| 162 | } |
| 163 | |
| 164 | func (e *FinalReadinessError) Error() string { |
| 165 | if e == nil { |
| 166 | return "final-answer readiness failed" |
| 167 | } |
| 168 | return fmt.Sprintf("final-answer readiness failed %d times: %s", e.Attempts, e.Reason) |
| 169 | } |
| 170 | |
| 171 | // RecoveryPauseError reports that Auto recovery exhausted its Episode budget |
| 172 | // and the model either summarized or continued calling tools after the one-shot |
| 173 | // finalization round. It is a control-flow signal, not a provider failure: |
| 174 | // completed work is kept and the user can continue in the next message. |
| 175 | type RecoveryPauseError struct { |
| 176 | // Message is the user-facing English product copy for wire/CLI clients. |
| 177 | Message string |
| 178 | // StopReason is an internal classifier; never show it as product copy. |
| 179 | StopReason string |
| 180 | // Detail is optional expandable diagnostic text (last error / counts). |
| 181 | Detail string |
| 182 | } |
| 183 | |
| 184 | func (e *RecoveryPauseError) Error() string { |
| 185 | if e == nil { |
| 186 | return "automatic retries paused" |
| 187 | } |
| 188 | if strings.TrimSpace(e.Message) != "" { |
| 189 | return e.Message |
| 190 | } |
| 191 | return "Automatic retries paused. Reasonix stopped repeated attempts and kept completed work. Send \"continue\" to start a fresh attempt, or add instructions to change direction." |
| 192 | } |
| 193 | |
| 194 | // CompletionUncertainContextTool is the retained host-safety cause for a |
| 195 | // context-unavailable tool being called again after the repair instruction. |
| 196 | const CompletionUncertainContextTool = "context_tool_repeat" |
| 197 | |
| 198 | // CompletionUncertainError reports that a host safety condition paused the |
| 199 | // current turn after completed work was retained. It is a control-flow signal, |
| 200 | // not a provider failure: the candidate answer, tool results, and completed |
| 201 | // work stay in the session, and the user can continue in the next message. |
| 202 | type CompletionUncertainError struct { |
| 203 | // Cause is the stable classifier naming why completion stayed unconfirmed. |
| 204 | Cause string |
| 205 | // Message is the user-facing English product copy for wire/CLI clients. |
| 206 | Message string |
| 207 | // Detail is optional expandable diagnostic text; never product copy. |
| 208 | Detail string |
| 209 | } |
| 210 | |
| 211 | func (e *CompletionUncertainError) Error() string { |
| 212 | if e == nil { |
| 213 | return "completion could not be confirmed" |
| 214 | } |
| 215 | if strings.TrimSpace(e.Message) != "" { |
| 216 | return e.Message |
| 217 | } |
| 218 | return "Completion could not be confirmed. Reasonix kept the current result and all completed work. Send \"continue\" to resume, or restate what should change." |
| 219 | } |
| 220 |