返回 DeepSeek-Reasonix
tool_result_capability.go
根目录 / internal / agent / tool_result_capability.go
1 package agent
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "slices"
10 "strings"
11 "unicode/utf8"
12
13 "reasonix/internal/provider"
14 "reasonix/internal/tool"
15 )
16
17 const (
18 sessionToolResultCapabilityID = "session:tool_result"
19 toolResultPageDefaultBytes = 16 * 1024
20 toolResultPageMaxBytes = 24 * 1024
21 )
22
23 type toolResultSessionBinder interface {
24 bindToolResultSession(func() *Session)
25 }
26
27 type sessionToolResultTool struct {
28 session func() *Session
29 }
30
31 func (*sessionToolResultTool) Name() string { return tool.HostSessionToolResult }
32
33 func (*sessionToolResultTool) Description() string {
34 return "Read one bounded UTF-8 page from a complete tool result retained in the current agent session. This uses tr-... result references; for a sa_... subagent reference, use read_subagent_result instead."
35 }
36
37 func (*sessionToolResultTool) ReadOnly() bool { return true }
38
39 func (*sessionToolResultTool) Schema() json.RawMessage {
40 return json.RawMessage(`{
41 "type":"object",
42 "properties":{
43 "tool_call_id":{"type":"string"},
44 "result_ref":{"type":"string"},
45 "offset":{"type":"integer","minimum":0},
46 "limit":{"type":"integer","minimum":1,"maximum":24576}
47 },
48 "required":["tool_call_id"]
49 }`)
50 }
51
52 type toolResultReadParams struct {
53 ToolCallID string `json:"tool_call_id"`
54 ResultRef string `json:"result_ref"`
55 Offset int `json:"offset"`
56 Limit int `json:"limit"`
57 }
58
59 type toolResultCandidate struct {
60 name string
61 body string
62 resultRef string
63 recoverable bool
64 requiresRef bool
65 }
66
67 func toolResultRef(toolCallID, body string) string {
68 h := sha256.New()
69 _, _ = h.Write([]byte(toolCallID))
70 _, _ = h.Write([]byte{0})
71 _, _ = h.Write([]byte(body))
72 return fmt.Sprintf("tr-%x", h.Sum(nil)[:12])
73 }
74
75 func toolOutputRecoveryMarker(toolName, toolCallID, resultRef string, originalBytes, keptBytes int) string {
76 namePart := boundedMarkerField(toolName, 128, "tool")
77 idPart := boundedMarkerField(toolCallID, 128, "-")
78 exampleID := toolCallID
79 if len(exampleID) > 256 {
80 exampleID = "<full tool_call_id from this tool result>"
81 }
82 args, _ := json.Marshal(struct {
83 ToolCallID string `json:"tool_call_id"`
84 ResultRef string `json:"result_ref"`
85 Offset int `json:"offset"`
86 }{ToolCallID: exampleID, ResultRef: resultRef})
87 return fmt.Sprintf(
88 "\n\n…[truncated tool=%s call_id=%s result_ref=%s original_bytes=%d kept_bytes=%d — full original retained locally; recover with use_capability(action=\"call\", capability_id=\"session:tool_result\", arguments=%s). If use_capability is unavailable, re-run the original tool with narrower arguments]…\n\n",
89 namePart, idPart, resultRef, originalBytes, keptBytes, args,
90 )
91 }
92
93 // toolOutputRecoveryMarkerAt builds the recoverable provider-visible marker.
94 // recoverOffset is the first byte the model has not seen. It is intentionally
95 // read_file-only; generic head/tail previews retain their byte-compatible marker
96 // above and continue to recover from zero.
97 func toolOutputRecoveryMarkerAt(toolName, toolCallID, resultRef string, originalBytes, keptBytes, recoverOffset int) string {
98 namePart := boundedMarkerField(toolName, 128, "tool")
99 idPart := boundedMarkerField(toolCallID, 128, "-")
100 exampleID := toolCallID
101 if len(exampleID) > 256 {
102 exampleID = "<full tool_call_id from this tool result>"
103 }
104 args, _ := json.Marshal(struct {
105 ToolCallID string `json:"tool_call_id"`
106 ResultRef string `json:"result_ref"`
107 Offset int `json:"offset"`
108 }{ToolCallID: exampleID, ResultRef: resultRef, Offset: recoverOffset})
109 return fmt.Sprintf(
110 "\n\n…[truncated tool=%s call_id=%s result_ref=%s original_bytes=%d kept_bytes=%d next_offset=%d — full original retained locally; recover with use_capability(action=\"call\", capability_id=\"session:tool_result\", arguments=%s). INCOMPLETE READ: only a contiguous prefix is visible. Independent work may continue; recover more content when required and do not claim whole-file coverage from this prefix. If use_capability is unavailable, re-run the original tool with narrower arguments]…\n\n",
111 namePart, idPart, resultRef, originalBytes, keptBytes, recoverOffset, args,
112 )
113 }
114
115 func boundedMarkerField(value string, maxBytes int, fallback string) string {
116 if value == "" {
117 return fallback
118 }
119 if len(value) <= maxBytes {
120 return value
121 }
122 return snapToRuneBoundary(value, 0, maxBytes) + "…"
123 }
124
125 func (t *sessionToolResultTool) Execute(_ context.Context, args json.RawMessage) (string, error) {
126 var p toolResultReadParams
127 if err := json.Unmarshal(args, &p); err != nil {
128 return "", fmt.Errorf("session tool result: invalid args: %w", err)
129 }
130 p.ToolCallID = strings.TrimSpace(p.ToolCallID)
131 p.ResultRef = strings.TrimSpace(p.ResultRef)
132 if strings.HasPrefix(p.ToolCallID, "sa_") || strings.HasPrefix(p.ResultRef, "sa_") {
133 return "", fmt.Errorf("session tool result: %q is a subagent reference; use read_subagent_result with ref", firstNonEmpty(p.ResultRef, p.ToolCallID))
134 }
135 if p.ToolCallID == "" {
136 return "", fmt.Errorf("session tool result: tool_call_id is required")
137 }
138 if p.Offset < 0 {
139 return "", fmt.Errorf("session tool result: offset must be non-negative")
140 }
141 if p.Limit == 0 {
142 p.Limit = toolResultPageDefaultBytes
143 }
144 if p.Limit < 1 || p.Limit > toolResultPageMaxBytes {
145 return "", fmt.Errorf("session tool result: limit must be between 1 and %d bytes", toolResultPageMaxBytes)
146 }
147 if t == nil || t.session == nil {
148 return "", fmt.Errorf("session tool result: current session is unavailable")
149 }
150 session := t.session()
151 if session == nil {
152 return "", fmt.Errorf("session tool result: current session is unavailable")
153 }
154 candidate, err := findToolResultCandidate(session.Snapshot(), p.ToolCallID, p.ResultRef)
155 if err != nil {
156 return "", err
157 }
158 if !candidate.recoverable {
159 return "", fmt.Errorf("session tool result: full result is unavailable for this legacy truncated record; re-run %s with narrower arguments", candidate.name)
160 }
161 if !utf8.ValidString(candidate.body) {
162 return "", fmt.Errorf("session tool result: retained result is not valid UTF-8")
163 }
164 if p.Offset > len(candidate.body) {
165 return "", fmt.Errorf("session tool result: offset %d exceeds total_bytes %d", p.Offset, len(candidate.body))
166 }
167 if p.Offset < len(candidate.body) && !utf8.RuneStart(candidate.body[p.Offset]) {
168 return "", fmt.Errorf("session tool result: offset %d is not a UTF-8 character boundary", p.Offset)
169 }
170
171 end := min(len(candidate.body), p.Offset+p.Limit)
172 for end > p.Offset && end < len(candidate.body) && !utf8.RuneStart(candidate.body[end]) {
173 end--
174 }
175 if end == p.Offset && end < len(candidate.body) {
176 return "", fmt.Errorf("session tool result: limit %d ends inside the next UTF-8 character; increase limit", p.Limit)
177 }
178 digest := sha256.Sum256([]byte(candidate.body))
179 header, _ := json.Marshal(struct {
180 ResultRef string `json:"result_ref"`
181 Offset int `json:"offset"`
182 NextOffset int `json:"next_offset"`
183 TotalBytes int `json:"total_bytes"`
184 SHA256 string `json:"sha256"`
185 Complete bool `json:"complete"`
186 }{
187 ResultRef: candidate.resultRef, Offset: p.Offset, NextOffset: end,
188 TotalBytes: len(candidate.body), SHA256: hex.EncodeToString(digest[:]), Complete: end == len(candidate.body),
189 })
190 return string(header) + "\n" + candidate.body[p.Offset:end], nil
191 }
192
193 func findToolResultCandidate(msgs []provider.Message, toolCallID, resultRef string) (toolResultCandidate, error) {
194 candidates := make([]toolResultCandidate, 0, 2)
195 for _, msg := range slices.Backward(msgs) {
196 if msg.Role != provider.RoleTool || msg.ToolCallID != toolCallID {
197 continue
198 }
199 body := msg.RawContent
200 recoverable := body != ""
201 if body == "" {
202 body = msg.Content
203 recoverable = !looksLikeTruncatedToolResult(msg.Content)
204 }
205 ref := toolResultRef(toolCallID, body)
206 requiresRef := strings.Contains(msg.Content, "…[truncated tool=") && strings.Contains(msg.Content, " result_ref=")
207 if !recoverable && requiresRef {
208 if markerRef, ok := toolResultRefFromMarker(msg.Content); ok {
209 ref = markerRef
210 }
211 }
212 candidate := toolResultCandidate{
213 name: msg.Name, body: body, resultRef: ref, recoverable: recoverable,
214 requiresRef: requiresRef,
215 }
216 if resultRef != "" {
217 if ref == resultRef {
218 return candidate, nil
219 }
220 continue
221 }
222 candidates = append(candidates, candidate)
223 }
224 if resultRef != "" {
225 return toolResultCandidate{}, fmt.Errorf("session tool result: result_ref %q was not found for tool_call_id %q", resultRef, toolCallID)
226 }
227 if len(candidates) == 0 {
228 return toolResultCandidate{}, fmt.Errorf("session tool result: tool_call_id %q was not found in the current session", toolCallID)
229 }
230 if len(candidates) == 1 {
231 if candidates[0].requiresRef {
232 return toolResultCandidate{}, fmt.Errorf("session tool result: result_ref is required for this truncated result; use result_ref=%s from its marker", candidates[0].resultRef)
233 }
234 return candidates[0], nil
235 }
236 refs := make([]string, 0, len(candidates))
237 for _, candidate := range candidates {
238 refs = append(refs, candidate.resultRef)
239 }
240 return toolResultCandidate{}, fmt.Errorf("session tool result: tool_call_id %q is ambiguous; retry with one of result_ref=%s", toolCallID, strings.Join(refs, ","))
241 }
242
243 func toolResultRefFromMarker(content string) (string, bool) {
244 const markerStart = "…[truncated tool="
245 start := strings.Index(content, markerStart)
246 if start < 0 {
247 return "", false
248 }
249 marker := content[start:]
250 if end := strings.Index(marker, "]…"); end >= 0 {
251 marker = marker[:end]
252 }
253 for field := range strings.FieldsSeq(marker) {
254 ref, ok := strings.CutPrefix(field, "result_ref=")
255 if !ok || len(ref) != len("tr-")+24 || !strings.HasPrefix(ref, "tr-") {
256 continue
257 }
258 if decoded, err := hex.DecodeString(strings.TrimPrefix(ref, "tr-")); err == nil && len(decoded) == 12 {
259 return ref, true
260 }
261 }
262 return "", false
263 }
264
265 func looksLikeTruncatedToolResult(content string) bool {
266 return strings.Contains(content, "…[truncated tool=") ||
267 strings.Contains(content, snippedMarker) ||
268 strings.Contains(content, prunedMarker) ||
269 strings.Contains(content, toolPruneMarker)
270 }
271
272 func (a *Agent) bindToolResultSessionCapability() {
273 if a == nil || a.svc.tools == nil {
274 return
275 }
276 proxy, ok := a.svc.tools.Get("use_capability")
277 if !ok {
278 return
279 }
280 binder, ok := proxy.(toolResultSessionBinder)
281 if !ok {
282 return
283 }
284 binder.bindToolResultSession(func() *Session { return a.Session() })
285 }
286
287 func (t *UseCapabilityTool) bindToolResultSession(session func() *Session) {
288 if t == nil {
289 return
290 }
291 t.toolResultMu.Lock()
292 t.toolResultSession = session
293 t.toolResultMu.Unlock()
294 }
295
296 func (t *UseCapabilityTool) currentToolResultTarget() tool.Tool {
297 if t == nil {
298 return nil
299 }
300 t.toolResultMu.RLock()
301 session := t.toolResultSession
302 t.toolResultMu.RUnlock()
303 if session == nil {
304 return nil
305 }
306 return &sessionToolResultTool{session: session}
307 }
308
309 func (t *UseCapabilityTool) resolveSessionToolResult(args json.RawMessage, base tool.ResolvedCall) (tool.ResolvedCall, error) {
310 target := t.currentToolResultTarget()
311 if target == nil {
312 return tool.ResolvedCall{}, fmt.Errorf("capability %q is unavailable without a current agent session", sessionToolResultCapabilityID)
313 }
314 base.TargetName = target.Name()
315 base.Target = target
316 base.Args = args
317 base.ReadOnly = true
318 return base, nil
319 }
320
321 func (t *UseCapabilityTool) inspectSessionToolResult() (string, error) {
322 if t.currentToolResultTarget() == nil {
323 return "", fmt.Errorf("capability %q is unavailable without a current agent session", sessionToolResultCapabilityID)
324 }
325 payload := map[string]any{
326 "id": sessionToolResultCapabilityID, "kind": "session", "name": "tool_result",
327 "description": "Read one bounded page from a complete tool result retained in this agent's current session.",
328 "status": "ready", "read_only": true,
329 "arguments": map[string]any{
330 "tool_call_id": "required", "result_ref": "required for new truncated results; optional for unambiguous legacy records",
331 "offset": 0, "limit_default": toolResultPageDefaultBytes, "limit_max": toolResultPageMaxBytes,
332 },
333 }
334 b, err := json.MarshalIndent(payload, "", " ")
335 return string(b), err
336 }
337
337 lines GO