| 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 |