返回 DeepSeek-Reasonix
subagent_outcome.go
根目录 / internal / agent / subagent_outcome.go
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
269 lines GO