返回 DeepSeek-Reasonix
subagent_result.go
根目录 / internal / agent / subagent_result.go
1 package agent
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "slices"
9 "strings"
10 "unicode/utf8"
11
12 "reasonix/internal/provider"
13 "reasonix/internal/tool"
14 )
15
16 const (
17 // Leave room for the generic tool-result guard to add metadata without
18 // clipping a task manifest. The aggregate itself owns fair per-task preview
19 // allocation so every completed child keeps its status and retrieval ref.
20 subagentAggregateBudgetBytes = maxToolOutputBytes - 512
21 subagentResultDefaultBytes = 12 * 1024
22 subagentResultMaxBytes = 24 * 1024
23 )
24
25 // SubagentResultTool pages through the retained answer of a persisted
26 // sub-agent, including partial and retryable failures. Parallel/fleet
27 // aggregates use this stable reader instead of forcing every child answer
28 // through one fixed-size tool result.
29 type SubagentResultTool struct {
30 store *SubagentStore
31 workspaceRoot string
32 }
33
34 func NewSubagentResultTool(task *TaskTool) *SubagentResultTool {
35 if task == nil {
36 return &SubagentResultTool{}
37 }
38 return &SubagentResultTool{store: task.transcripts, workspaceRoot: task.workspaceRoot}
39 }
40
41 func (*SubagentResultTool) Name() string { return tool.HostReadSubagentResult }
42
43 func (*SubagentResultTool) Description() string {
44 return "Read a completed or partial sub-agent's retained answer by the Subagent reference returned from task, parallel_tasks, or fleet. Failed runs may expose their last useful output and an explicit retryability status. Results are scoped to the current conversation lineage and paged by UTF-8 byte offset so large answers remain lossless without overflowing one tool result."
45 }
46
47 func (*SubagentResultTool) Schema() json.RawMessage {
48 return json.RawMessage(`{"type":"object","properties":{"ref":{"type":"string","description":"The sa_... value from a Subagent reference line."},"offset_bytes":{"type":"integer","description":"UTF-8 byte offset to start reading from. Omit for the beginning; use next_offset_bytes from the previous page.","minimum":0},"limit_bytes":{"type":"integer","description":"Maximum UTF-8 bytes to return. Defaults to 12288 and is capped at 24576.","minimum":1,"maximum":24576}},"required":["ref"]}`)
49 }
50
51 func (*SubagentResultTool) ReadOnly() bool { return true }
52
53 func (*SubagentResultTool) PlanModeSafe() bool { return true }
54
55 func (t *SubagentResultTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
56 var p struct {
57 Ref string `json:"ref"`
58 OffsetBytes int `json:"offset_bytes"`
59 LimitBytes int `json:"limit_bytes"`
60 }
61 dec := json.NewDecoder(bytes.NewReader(args))
62 dec.DisallowUnknownFields()
63 if err := dec.Decode(&p); err != nil {
64 return "", fmt.Errorf("invalid args: %w", err)
65 }
66 p.Ref = strings.TrimSpace(p.Ref)
67 if p.Ref == "" {
68 return "", fmt.Errorf("ref is required")
69 }
70 if p.OffsetBytes < 0 {
71 return "", fmt.Errorf("offset_bytes must be non-negative")
72 }
73 if p.LimitBytes == 0 {
74 p.LimitBytes = subagentResultDefaultBytes
75 }
76 if p.LimitBytes < 1 || p.LimitBytes > subagentResultMaxBytes {
77 return "", fmt.Errorf("limit_bytes must be between 1 and %d", subagentResultMaxBytes)
78 }
79 if t == nil || t.store == nil {
80 return "", fmt.Errorf("subagent result storage is not available in this session")
81 }
82 parentSession := ParentSession(ctx)
83 if parentSession == "" {
84 return "", fmt.Errorf("subagent result retrieval requires a persisted parent session")
85 }
86
87 answer, status, err := t.store.ReadFinalAnswer(p.Ref, parentSession, t.workspaceRoot)
88 if err != nil {
89 return "", err
90 }
91 if p.OffsetBytes > len(answer) {
92 return "", fmt.Errorf("offset_bytes %d exceeds result size %d", p.OffsetBytes, len(answer))
93 }
94 if p.OffsetBytes < len(answer) && !utf8.RuneStart(answer[p.OffsetBytes]) {
95 return "", fmt.Errorf("offset_bytes %d is not at a UTF-8 character boundary; use next_offset_bytes from the previous page", p.OffsetBytes)
96 }
97 end := min(p.OffsetBytes+p.LimitBytes, len(answer))
98 for end > p.OffsetBytes && end < len(answer) && !utf8.RuneStart(answer[end]) {
99 end--
100 }
101
102 var b strings.Builder
103 fmt.Fprintf(&b, "Subagent result %s (status=%s, bytes %d-%d of %d):\n", p.Ref, status, p.OffsetBytes, end, len(answer))
104 b.WriteString(answer[p.OffsetBytes:end])
105 if end < len(answer) {
106 fmt.Fprintf(&b, "\n\nMore remains. Call read_subagent_result with ref=%q and offset_bytes=%d.", p.Ref, end)
107 } else {
108 b.WriteString("\n\nEnd of subagent result.")
109 }
110 return b.String(), nil
111 }
112
113 // ReadFinalAnswer returns a completed child answer only when the caller owns
114 // the parent conversation (or a verified descendant) and the workspace still
115 // matches. The per-ref lock prevents a read racing the terminal transcript save.
116 func (s *SubagentStore) ReadFinalAnswer(ref, parentSession, workspaceRoot string) (string, SubagentStatus, error) {
117 if s == nil {
118 return "", "", fmt.Errorf("subagent result storage is not available")
119 }
120 ref = strings.TrimSpace(ref)
121 parentSession = strings.TrimSpace(parentSession)
122 if parentSession == "" {
123 return "", "", fmt.Errorf("subagent result parent session is required")
124 }
125 release, err := s.lock(ref)
126 if err != nil {
127 return "", "", err
128 }
129 defer release()
130
131 meta, err := s.LoadMeta(ref)
132 if err != nil {
133 return "", "", err
134 }
135 owner := strings.TrimSpace(meta.ParentSession)
136 if owner != parentSession {
137 ok, lineageErr := s.isAncestorSession(owner, parentSession)
138 if lineageErr != nil {
139 return "", meta.Status, fmt.Errorf("subagent reference %q ownership could not be verified: %w", ref, lineageErr)
140 }
141 if !ok {
142 return "", meta.Status, fmt.Errorf("subagent reference %q does not belong to the current conversation lineage", ref)
143 }
144 }
145 if want := strings.TrimSpace(workspaceRoot); want != "" && strings.TrimSpace(meta.WorkspaceRoot) != want {
146 return "", meta.Status, fmt.Errorf("subagent reference %q belongs to a different workspace", ref)
147 }
148 if meta.Status == SubagentRunning {
149 return "", meta.Status, fmt.Errorf("subagent reference %q is still in progress", ref)
150 }
151 if meta.Status == SubagentInterrupted && meta.Outcome != string(SubagentOutcomeCancelled) {
152 return "", meta.Status, fmt.Errorf("subagent reference %q was interrupted; only completed or retained partial results can be read", ref)
153 }
154
155 sess, err := LoadSession(s.sessionPath(ref))
156 if err != nil {
157 return "", meta.Status, fmt.Errorf("load subagent transcript %q: %w", ref, err)
158 }
159 msgs := sess.Snapshot()
160 for _, v := range slices.Backward(msgs) {
161 if v.Role == provider.RoleAssistant && strings.TrimSpace(v.Content) != "" {
162 status := meta.Status
163 if meta.Outcome != "" {
164 status = SubagentStatus(meta.Outcome)
165 }
166 return v.Content, status, nil
167 }
168 }
169 return "", meta.Status, fmt.Errorf("subagent reference %q has no final assistant answer", ref)
170 }
171
172 type subagentAggregateItem struct {
173 header string
174 status string
175 answer string
176 ref string
177 detail string
178 }
179
180 func formatBoundedSubagentAggregate(prefix string, items []subagentAggregateItem) string {
181 // Attestations are reserved before prose gets any budget: a long child
182 // answer must never truncate away what the host saw it change. They
183 // degrade to header plus violations only if they would starve previews.
184 prose := make([]string, len(items))
185 receipts := make([]string, len(items))
186 receiptBytes := 0
187 for i, item := range items {
188 prose[i], receipts[i] = splitHostReceipts(item.answer)
189 receiptBytes += len(receipts[i]) + 1
190 }
191 if reserve := subagentAggregateBudgetBytes / 2; receiptBytes > reserve && len(items) > 0 {
192 receiptBytes = 0
193 for i := range receipts {
194 receipts[i] = boundedHostReceipts(receipts[i], reserve/len(items))
195 receiptBytes += len(receipts[i]) + 1
196 }
197 }
198
199 baseBytes := len(prefix) + receiptBytes
200 completed := 0
201 for i, item := range items {
202 baseBytes += len(item.header) + len(item.status) + len(item.detail)
203 if item.ref != "" {
204 baseBytes += len("Subagent reference: \n") + len(item.ref)
205 }
206 if prose[i] != "" {
207 baseBytes += len("Final answer preview:\n\n")
208 completed++
209 }
210 }
211 available := max(subagentAggregateBudgetBytes-baseBytes, 0)
212 perAnswer := 0
213 if completed > 0 {
214 perAnswer = available / completed
215 }
216
217 var b strings.Builder
218 b.Grow(minInt(subagentAggregateBudgetBytes, baseBytes+available))
219 b.WriteString(prefix)
220 for i, item := range items {
221 b.WriteString(item.header)
222 b.WriteString(item.status)
223 if item.ref != "" {
224 fmt.Fprintf(&b, "Subagent reference: %s\n", item.ref)
225 }
226 if item.detail != "" {
227 b.WriteString(item.detail)
228 }
229 if prose[i] != "" {
230 b.WriteString("Final answer preview:\n")
231 b.WriteString(subagentAnswerPreview(prose[i], item.ref, perAnswer))
232 b.WriteByte('\n')
233 }
234 if receipts[i] != "" {
235 b.WriteString(receipts[i])
236 b.WriteByte('\n')
237 }
238 }
239 return b.String()
240 }
241
242 func subagentAnswerPreview(answer, ref string, limit int) string {
243 answer = strings.TrimSpace(answer)
244 if len(answer) <= limit {
245 return answer
246 }
247 if limit <= 0 {
248 return ""
249 }
250 marker := "\n…[preview truncated; full result unavailable in this ephemeral run]…\n"
251 if ref != "" {
252 marker = fmt.Sprintf("\n…[preview truncated; read the full result with read_subagent_result(ref=%q)]…\n", ref)
253 }
254 if len(marker) >= limit {
255 return utf8Prefix(answer, limit)
256 }
257 keep := limit - len(marker)
258 headBytes := keep / 2
259 tailBytes := keep - headBytes
260 head := utf8Prefix(answer, headBytes)
261 tail := utf8Suffix(answer, tailBytes)
262 return head + marker + tail
263 }
264
265 func utf8Prefix(s string, limit int) string {
266 if limit >= len(s) {
267 return s
268 }
269 if limit <= 0 {
270 return ""
271 }
272 for limit > 0 && !utf8.RuneStart(s[limit]) {
273 limit--
274 }
275 return s[:limit]
276 }
277
278 func utf8Suffix(s string, limit int) string {
279 if limit >= len(s) {
280 return s
281 }
282 if limit <= 0 {
283 return ""
284 }
285 start := len(s) - limit
286 for start < len(s) && !utf8.RuneStart(s[start]) {
287 start++
288 }
289 return s[start:]
290 }
291
292 func boundedInline(s string, limit int) string {
293 s = strings.Join(strings.Fields(s), " ")
294 if len(s) <= limit {
295 return s
296 }
297 if limit <= len("…") {
298 return utf8Prefix(s, limit)
299 }
300 return utf8Prefix(s, limit-len("…")) + "…"
301 }
302
303 func splitSubagentRunResult(output string) (answer, ref string) {
304 ref = extractSubagentRef(output)
305 if ref == "" {
306 return strings.TrimSpace(output), ""
307 }
308 const marker = "\n\nFinal answer:\n"
309 if _, after, ok := strings.Cut(output, marker); ok {
310 return strings.TrimSpace(after), ref
311 }
312 return strings.TrimSpace(output), ref
313 }
314
315 func extractSubagentRef(output string) string {
316 const prefix = "Subagent reference: "
317 if !strings.HasPrefix(output, prefix) {
318 return ""
319 }
320 line := output
321 if end := strings.IndexByte(line, '\n'); end >= 0 {
322 line = line[:end]
323 }
324 ref := strings.TrimSpace(strings.TrimPrefix(line, prefix))
325 if validSubagentRef(ref) {
326 return ref
327 }
328 return ""
329 }
330
330 lines GO