返回 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 "strings"
9 "unicode/utf8"
10
11 "reasonix/internal/provider"
12 )
13
14 const (
15 // Leave room for the generic tool-result guard to add metadata without
16 // clipping a task manifest. The aggregate itself owns fair per-task preview
17 // allocation so every completed child keeps its status and retrieval ref.
18 subagentAggregateBudgetBytes = maxToolOutputBytes - 512
19 subagentResultDefaultBytes = 12 * 1024
20 subagentResultMaxBytes = 24 * 1024
21 )
22
23 // SubagentResultTool pages through the final answer of a completed persisted
24 // sub-agent. Parallel/fleet aggregates use this stable reader instead of
25 // forcing every child answer through one fixed-size tool result.
26 type SubagentResultTool struct {
27 store *SubagentStore
28 workspaceRoot string
29 }
30
31 func NewSubagentResultTool(task *TaskTool) *SubagentResultTool {
32 if task == nil {
33 return &SubagentResultTool{}
34 }
35 return &SubagentResultTool{store: task.transcripts, workspaceRoot: task.workspaceRoot}
36 }
37
38 func (*SubagentResultTool) Name() string { return "read_subagent_result" }
39
40 func (*SubagentResultTool) Description() string {
41 return "Read a completed sub-agent's full final answer by the Subagent reference returned from task, parallel_tasks, or fleet. 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."
42 }
43
44 func (*SubagentResultTool) Schema() json.RawMessage {
45 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"]}`)
46 }
47
48 func (*SubagentResultTool) ReadOnly() bool { return true }
49
50 func (*SubagentResultTool) PlanModeSafe() bool { return true }
51
52 func (t *SubagentResultTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
53 var p struct {
54 Ref string `json:"ref"`
55 OffsetBytes int `json:"offset_bytes"`
56 LimitBytes int `json:"limit_bytes"`
57 }
58 dec := json.NewDecoder(bytes.NewReader(args))
59 dec.DisallowUnknownFields()
60 if err := dec.Decode(&p); err != nil {
61 return "", fmt.Errorf("invalid args: %w", err)
62 }
63 p.Ref = strings.TrimSpace(p.Ref)
64 if p.Ref == "" {
65 return "", fmt.Errorf("ref is required")
66 }
67 if p.OffsetBytes < 0 {
68 return "", fmt.Errorf("offset_bytes must be non-negative")
69 }
70 if p.LimitBytes == 0 {
71 p.LimitBytes = subagentResultDefaultBytes
72 }
73 if p.LimitBytes < 1 || p.LimitBytes > subagentResultMaxBytes {
74 return "", fmt.Errorf("limit_bytes must be between 1 and %d", subagentResultMaxBytes)
75 }
76 if t == nil || t.store == nil {
77 return "", fmt.Errorf("subagent result storage is not available in this session")
78 }
79 parentSession := ParentSession(ctx)
80 if parentSession == "" {
81 return "", fmt.Errorf("subagent result retrieval requires a persisted parent session")
82 }
83
84 answer, status, err := t.store.ReadFinalAnswer(p.Ref, parentSession, t.workspaceRoot)
85 if err != nil {
86 return "", err
87 }
88 if p.OffsetBytes > len(answer) {
89 return "", fmt.Errorf("offset_bytes %d exceeds result size %d", p.OffsetBytes, len(answer))
90 }
91 if p.OffsetBytes < len(answer) && !utf8.RuneStart(answer[p.OffsetBytes]) {
92 return "", fmt.Errorf("offset_bytes %d is not at a UTF-8 character boundary; use next_offset_bytes from the previous page", p.OffsetBytes)
93 }
94 end := p.OffsetBytes + p.LimitBytes
95 if end > len(answer) {
96 end = len(answer)
97 }
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 != SubagentCompleted {
149 return "", meta.Status, fmt.Errorf("subagent reference %q is %s; only completed results can be read", ref, meta.Status)
150 }
151
152 sess, err := LoadSession(s.sessionPath(ref))
153 if err != nil {
154 return "", meta.Status, fmt.Errorf("load subagent transcript %q: %w", ref, err)
155 }
156 msgs := sess.Snapshot()
157 for i := len(msgs) - 1; i >= 0; i-- {
158 if msgs[i].Role == provider.RoleAssistant && strings.TrimSpace(msgs[i].Content) != "" {
159 return msgs[i].Content, meta.Status, nil
160 }
161 }
162 return "", meta.Status, fmt.Errorf("subagent reference %q has no final assistant answer", ref)
163 }
164
165 type subagentAggregateItem struct {
166 header string
167 status string
168 answer string
169 ref string
170 detail string
171 }
172
173 func formatBoundedSubagentAggregate(prefix string, items []subagentAggregateItem) string {
174 baseBytes := len(prefix)
175 completed := 0
176 for _, item := range items {
177 baseBytes += len(item.header) + len(item.status) + len(item.detail)
178 if item.ref != "" {
179 baseBytes += len("Subagent reference: \n") + len(item.ref)
180 }
181 if item.answer != "" {
182 baseBytes += len("Final answer preview:\n\n")
183 completed++
184 }
185 }
186 available := subagentAggregateBudgetBytes - baseBytes
187 if available < 0 {
188 available = 0
189 }
190 perAnswer := 0
191 if completed > 0 {
192 perAnswer = available / completed
193 }
194
195 var b strings.Builder
196 b.Grow(minInt(subagentAggregateBudgetBytes, baseBytes+available))
197 b.WriteString(prefix)
198 for _, item := range items {
199 b.WriteString(item.header)
200 b.WriteString(item.status)
201 if item.ref != "" {
202 fmt.Fprintf(&b, "Subagent reference: %s\n", item.ref)
203 }
204 if item.detail != "" {
205 b.WriteString(item.detail)
206 }
207 if item.answer != "" {
208 b.WriteString("Final answer preview:\n")
209 b.WriteString(subagentAnswerPreview(item.answer, item.ref, perAnswer))
210 b.WriteByte('\n')
211 }
212 }
213 return b.String()
214 }
215
216 func subagentAnswerPreview(answer, ref string, limit int) string {
217 answer = strings.TrimSpace(answer)
218 if len(answer) <= limit {
219 return answer
220 }
221 if limit <= 0 {
222 return ""
223 }
224 marker := "\n…[preview truncated; full result unavailable in this ephemeral run]…\n"
225 if ref != "" {
226 marker = fmt.Sprintf("\n…[preview truncated; read the full result with read_subagent_result(ref=%q)]…\n", ref)
227 }
228 if len(marker) >= limit {
229 return utf8Prefix(answer, limit)
230 }
231 keep := limit - len(marker)
232 headBytes := keep / 2
233 tailBytes := keep - headBytes
234 head := utf8Prefix(answer, headBytes)
235 tail := utf8Suffix(answer, tailBytes)
236 return head + marker + tail
237 }
238
239 func utf8Prefix(s string, limit int) string {
240 if limit >= len(s) {
241 return s
242 }
243 if limit <= 0 {
244 return ""
245 }
246 for limit > 0 && !utf8.RuneStart(s[limit]) {
247 limit--
248 }
249 return s[:limit]
250 }
251
252 func utf8Suffix(s string, limit int) string {
253 if limit >= len(s) {
254 return s
255 }
256 if limit <= 0 {
257 return ""
258 }
259 start := len(s) - limit
260 for start < len(s) && !utf8.RuneStart(s[start]) {
261 start++
262 }
263 return s[start:]
264 }
265
266 func boundedInline(s string, limit int) string {
267 s = strings.Join(strings.Fields(s), " ")
268 if len(s) <= limit {
269 return s
270 }
271 if limit <= len("…") {
272 return utf8Prefix(s, limit)
273 }
274 return utf8Prefix(s, limit-len("…")) + "…"
275 }
276
277 func splitSubagentRunResult(output string) (answer, ref string) {
278 ref = extractSubagentRef(output)
279 if ref == "" {
280 return strings.TrimSpace(output), ""
281 }
282 const marker = "\n\nFinal answer:\n"
283 if idx := strings.Index(output, marker); idx >= 0 {
284 return strings.TrimSpace(output[idx+len(marker):]), ref
285 }
286 return strings.TrimSpace(output), ref
287 }
288
289 func extractSubagentRef(output string) string {
290 const prefix = "Subagent reference: "
291 if !strings.HasPrefix(output, prefix) {
292 return ""
293 }
294 line := output
295 if end := strings.IndexByte(line, '\n'); end >= 0 {
296 line = line[:end]
297 }
298 ref := strings.TrimSpace(strings.TrimPrefix(line, prefix))
299 if validSubagentRef(ref) {
300 return ref
301 }
302 return ""
303 }
304
304 lines GO