| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "strconv" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/provider" |
| 13 | "reasonix/internal/skill" |
| 14 | ) |
| 15 | |
| 16 | type SubagentOutcomeStatus string |
| 17 | |
| 18 | const ( |
| 19 | SubagentOutcomeCompleted SubagentOutcomeStatus = "completed" |
| 20 | SubagentOutcomePartial SubagentOutcomeStatus = "partial" |
| 21 | SubagentOutcomeFailed SubagentOutcomeStatus = "failed" |
| 22 | SubagentOutcomeCancelled SubagentOutcomeStatus = "cancelled" |
| 23 | ) |
| 24 | |
| 25 | // SubagentOutcome is the bounded handoff from a child run to its parent. |
| 26 | type SubagentOutcome struct { |
| 27 | Ref string `json:"ref"` |
| 28 | Status SubagentOutcomeStatus `json:"status"` |
| 29 | FinalAnswer string `json:"final_answer,omitempty"` |
| 30 | ErrorCode string `json:"error_code,omitempty"` |
| 31 | Retryable bool `json:"retryable"` |
| 32 | } |
| 33 | |
| 34 | // SubagentRunError carries a recoverable result envelope while preserving the |
| 35 | // original error for host status classification and logging. |
| 36 | type SubagentRunError struct { |
| 37 | Outcome SubagentOutcome |
| 38 | Cause error |
| 39 | } |
| 40 | |
| 41 | func (e *SubagentRunError) Error() string { |
| 42 | if e == nil || e.Cause == nil { |
| 43 | return "subagent run failed" |
| 44 | } |
| 45 | return e.Cause.Error() |
| 46 | } |
| 47 | |
| 48 | func (e *SubagentRunError) Unwrap() error { |
| 49 | if e == nil { |
| 50 | return nil |
| 51 | } |
| 52 | return e.Cause |
| 53 | } |
| 54 | |
| 55 | func (e *SubagentRunError) SubagentOutput() string { |
| 56 | if e == nil { |
| 57 | return "" |
| 58 | } |
| 59 | return FormatSubagentOutcome(e.Outcome) |
| 60 | } |
| 61 | |
| 62 | var _ skill.SubagentOutputError = (*SubagentRunError)(nil) |
| 63 | |
| 64 | func NewSubagentRunError(run *SubagentRun, cause error) *SubagentRunError { |
| 65 | outcome := SubagentOutcome{Status: SubagentOutcomeFailed} |
| 66 | if run != nil { |
| 67 | outcome.Ref = run.Ref |
| 68 | outcome.FinalAnswer = latestAssistantAnswer(run.Session) |
| 69 | } |
| 70 | if cause == nil { |
| 71 | cause = errors.New("subagent run failed") |
| 72 | } |
| 73 | outcome.ErrorCode, outcome.Status, outcome.Retryable = subagentErrorDisposition(cause) |
| 74 | return &SubagentRunError{Outcome: outcome, Cause: cause} |
| 75 | } |
| 76 | |
| 77 | func subagentOutcomeFromError(err error) *SubagentOutcome { |
| 78 | var subErr *SubagentRunError |
| 79 | if !errors.As(err, &subErr) { |
| 80 | return nil |
| 81 | } |
| 82 | outcome := subErr.Outcome |
| 83 | return &outcome |
| 84 | } |
| 85 | |
| 86 | func (t *TaskTool) failedSubagentResult(run *SubagentRun, cause error) (string, error) { |
| 87 | subErr := NewSubagentRunError(run, cause) |
| 88 | var saveErr error |
| 89 | if t != nil && t.transcripts != nil { |
| 90 | saveErr = t.transcripts.SaveOutcome(run, subErr.Outcome) |
| 91 | } |
| 92 | return subErr.SubagentOutput(), errors.Join(subErr, saveErr) |
| 93 | } |
| 94 | |
| 95 | // resolveAmbiguousSubagentFailure preserves the child result envelope for |
| 96 | // callers that still use the historical method name. Completion is decided by |
| 97 | // the child Agent's deterministic stream/tool state; no second model opinion is |
| 98 | // requested here. |
| 99 | func (t *TaskTool) resolveAmbiguousSubagentFailure(ctx context.Context, run *SubagentRun, taskText, modelRef string, sink event.Sink, cause error) (string, error) { |
| 100 | subErr := NewSubagentRunError(run, cause) |
| 101 | var saveErr error |
| 102 | if t != nil && t.transcripts != nil { |
| 103 | saveErr = t.transcripts.SaveOutcome(run, subErr.Outcome) |
| 104 | } |
| 105 | return subErr.SubagentOutput(), errors.Join(subErr, saveErr) |
| 106 | } |
| 107 | |
| 108 | // ResolveAmbiguousSubagentFailure applies the same bounded child completion |
| 109 | // policy to boot-wired skill runners. |
| 110 | func (t *TaskTool) ResolveAmbiguousSubagentFailure(ctx context.Context, run *SubagentRun, taskText, modelRef string, sink event.Sink, cause error) (string, error) { |
| 111 | return t.resolveAmbiguousSubagentFailure(ctx, run, taskText, modelRef, sink, cause) |
| 112 | } |
| 113 | |
| 114 | // failBeforeSubagentRelease preserves the run envelope on setup failures that |
| 115 | // occur after a reference was allocated but before the normal RunProfileSpec |
| 116 | // cleanup/defer is installed. |
| 117 | func (t *TaskTool) failBeforeSubagentRelease(run *SubagentRun, cause error) (string, error) { |
| 118 | result, err := t.failedSubagentResult(run, cause) |
| 119 | if run != nil { |
| 120 | run.Release() |
| 121 | } |
| 122 | return result, err |
| 123 | } |
| 124 | |
| 125 | func subagentErrorDisposition(err error) (string, SubagentOutcomeStatus, bool) { |
| 126 | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { |
| 127 | return "cancelled", SubagentOutcomeCancelled, false |
| 128 | } |
| 129 | var completion *CompletionUncertainError |
| 130 | if errors.As(err, &completion) { |
| 131 | return "completion_uncertain", SubagentOutcomePartial, true |
| 132 | } |
| 133 | var readiness *FinalReadinessError |
| 134 | if errors.As(err, &readiness) { |
| 135 | return "final_readiness", SubagentOutcomePartial, true |
| 136 | } |
| 137 | var review *ReviewUnavailableError |
| 138 | if errors.As(err, &review) { |
| 139 | return "review_unavailable", SubagentOutcomePartial, true |
| 140 | } |
| 141 | var steps *maxStepsPause |
| 142 | if errors.As(err, &steps) { |
| 143 | return "max_steps", SubagentOutcomePartial, true |
| 144 | } |
| 145 | var incomplete *IncompleteReadError |
| 146 | if errors.As(err, &incomplete) { |
| 147 | return "incomplete_read", SubagentOutcomePartial, true |
| 148 | } |
| 149 | var apiErr *provider.APIError |
| 150 | if errors.As(err, &apiErr) { |
| 151 | return fmt.Sprintf("provider_http_%d", apiErr.Status), SubagentOutcomeFailed, provider.RetryableStatus(apiErr.Status) |
| 152 | } |
| 153 | if provider.IsConnReset(err) { |
| 154 | return "provider_connection", SubagentOutcomeFailed, true |
| 155 | } |
| 156 | return "subagent_error", SubagentOutcomeFailed, false |
| 157 | } |
| 158 | |
| 159 | // FormatSubagentOutcome keeps the existing textual reference markers while |
| 160 | // making status and recovery explicit to the parent model and UI sinks. |
| 161 | func FormatSubagentOutcome(outcome SubagentOutcome) string { |
| 162 | var b strings.Builder |
| 163 | status := outcome.Status |
| 164 | if status == "" { |
| 165 | status = SubagentOutcomeFailed |
| 166 | } |
| 167 | if outcome.Ref != "" { |
| 168 | fmt.Fprintf(&b, "Subagent reference: %s\n", outcome.Ref) |
| 169 | } |
| 170 | fmt.Fprintf(&b, "Subagent outcome: status=%s retryable=%t", status, outcome.Retryable) |
| 171 | if outcome.ErrorCode != "" { |
| 172 | fmt.Fprintf(&b, " error_code=%s", outcome.ErrorCode) |
| 173 | } |
| 174 | b.WriteByte('\n') |
| 175 | if answer := strings.TrimSpace(outcome.FinalAnswer); answer != "" { |
| 176 | b.WriteString("\n\nFinal answer:\n") |
| 177 | b.WriteString(answer) |
| 178 | } |
| 179 | return strings.TrimSpace(b.String()) |
| 180 | } |
| 181 | |
| 182 | func ParseSubagentOutcome(text string) (SubagentOutcome, bool) { |
| 183 | var outcome SubagentOutcome |
| 184 | for line := range strings.SplitSeq(text, "\n") { |
| 185 | line = strings.TrimSpace(line) |
| 186 | if ref, ok := strings.CutPrefix(line, "Subagent reference: "); ok { |
| 187 | outcome.Ref = strings.TrimSpace(ref) |
| 188 | continue |
| 189 | } |
| 190 | if ref, ok := strings.CutPrefix(line, "Subagent reference (failed): "); ok { |
| 191 | outcome.Ref = strings.TrimSpace(ref) |
| 192 | continue |
| 193 | } |
| 194 | if status, ok := strings.CutPrefix(line, "Subagent outcome: status="); ok { |
| 195 | fields := strings.Fields(status) |
| 196 | if len(fields) == 0 { |
| 197 | continue |
| 198 | } |
| 199 | outcome.Status = SubagentOutcomeStatus(fields[0]) |
| 200 | for _, field := range fields[1:] { |
| 201 | if value, ok := strings.CutPrefix(field, "retryable="); ok { |
| 202 | outcome.Retryable, _ = strconv.ParseBool(value) |
| 203 | } |
| 204 | if value, ok := strings.CutPrefix(field, "error_code="); ok { |
| 205 | outcome.ErrorCode = value |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | } |
| 210 | return outcome, validSubagentRef(outcome.Ref) && validSubagentOutcomeStatus(outcome.Status) |
| 211 | } |
| 212 | |
| 213 | func validSubagentOutcomeStatus(status SubagentOutcomeStatus) bool { |
| 214 | switch status { |
| 215 | case SubagentOutcomeCompleted, SubagentOutcomePartial, SubagentOutcomeFailed, SubagentOutcomeCancelled: |
| 216 | return true |
| 217 | default: |
| 218 | return false |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | func emitSubagentLifecycle(sink event.Sink, phase, parentToolCallID, skillName, model, effort string, run *SubagentRun, outcome *SubagentOutcome) { |
| 223 | if run == nil || run.Ref == "" || sink == nil { |
| 224 | return |
| 225 | } |
| 226 | info := event.SubagentLifecycleInfo{ |
| 227 | Phase: phase, Ref: run.Ref, ParentToolCallID: parentToolCallID, |
| 228 | Skill: skillName, Model: model, Effort: effort, |
| 229 | } |
| 230 | if !run.Meta.CreatedAt.IsZero() { |
| 231 | info.StartUnixMs = run.Meta.CreatedAt.UnixMilli() |
| 232 | } |
| 233 | if outcome != nil { |
| 234 | info.Status = string(outcome.Status) |
| 235 | info.ErrorCode = outcome.ErrorCode |
| 236 | info.Retryable = outcome.Retryable |
| 237 | info.OutputBytes = len(outcome.FinalAnswer) |
| 238 | info.EndUnixMs = time.Now().UnixMilli() |
| 239 | } else if phase == "child_running" { |
| 240 | info.Status = "running" |
| 241 | } else { |
| 242 | info.Status = "queued" |
| 243 | } |
| 244 | event.RecordSubagentLifecycle(sink, info) |
| 245 | } |
| 246 | |
| 247 | // EmitSubagentLifecycle publishes a content-free lifecycle transition for a |
| 248 | // child runner implemented outside the agent package, such as boot-wired |
| 249 | // skills. |
| 250 | func EmitSubagentLifecycle(sink event.Sink, phase, parentToolCallID, skillName, model, effort string, run *SubagentRun, outcome *SubagentOutcome) { |
| 251 | emitSubagentLifecycle(sink, phase, parentToolCallID, skillName, model, effort, run, outcome) |
| 252 | } |
| 253 | |
| 254 | func terminalSubagentLifecycle(runErr error) (string, *SubagentOutcome) { |
| 255 | if runErr == nil { |
| 256 | return "child_completed", &SubagentOutcome{Status: SubagentOutcomeCompleted} |
| 257 | } |
| 258 | if outcome := subagentOutcomeFromError(runErr); outcome != nil { |
| 259 | return "child_" + string(outcome.Status), outcome |
| 260 | } |
| 261 | subErr := NewSubagentRunError(nil, runErr) |
| 262 | return "child_" + string(subErr.Outcome.Status), &subErr.Outcome |
| 263 | } |
| 264 | |
| 265 | // TerminalSubagentLifecycle classifies a runner error for lifecycle sinks. |
| 266 | func TerminalSubagentLifecycle(runErr error) (string, *SubagentOutcome) { |
| 267 | return terminalSubagentLifecycle(runErr) |
| 268 | } |
| 269 |