返回 DeepSeek-Reasonix
toolresult_projection.go
根目录 / internal / plugin / toolresult_projection.go
1 package plugin
2
3 import (
4 "bytes"
5 "encoding/base64"
6 "encoding/json"
7 "fmt"
8 "io"
9 "strings"
10 "unicode/utf8"
11 )
12
13 // mcpApplicationError is a successful tools/call response whose MCP isError
14 // flag is set. It is deterministic application feedback, not a transport
15 // failure, so the agent must never retry it merely because its human-readable
16 // message contains words such as "timeout" or "unavailable".
17 type mcpApplicationError struct {
18 message string
19 }
20
21 func (e *mcpApplicationError) Error() string { return e.message }
22
23 // RetryableToolError is consumed by the agent retry classifier without
24 // creating a plugin dependency in the tool package.
25 func (*mcpApplicationError) RetryableToolError() bool { return false }
26
27 // Rich MCP content is additive to the ordinary text projection. Bound it
28 // separately so structured data and embedded resources cannot unexpectedly
29 // consume an entire model context or smuggle large inline binary payloads into
30 // provider requests. Existing text blocks retain their historical behavior.
31 const (
32 maxToolResultRichProjectionBytes = 64 << 10
33 maxToolResultRichItemBytes = 32 << 10
34 maxToolResultImageBytes = 4 << 20 // encoded length; stays under provider per-image and request caps
35 maxToolResultImages = 5
36 )
37
38 var toolResultImageMimes = map[string]bool{
39 "image/jpeg": true,
40 "image/png": true,
41 "image/gif": true,
42 "image/webp": true,
43 }
44
45 // parseToolResult flattens an MCP tools/call result into provider-safe text plus
46 // image data URLs. Text and direct images preserve their historical projection;
47 // structured content, resource links, embedded resources, audio, and unknown
48 // future blocks receive bounded textual projections so they are never silently
49 // dropped or allowed to inject unbounded inline binary data.
50 func parseToolResult(res json.RawMessage) (string, []string, error) {
51 return parseToolResultProjection(res, true)
52 }
53
54 // parseToolResultForApp keeps the App transcript's historical text/image-only
55 // projection. The complete bounded result already travels over AppBridge, so
56 // repeating rich blocks in the local transcript would duplicate content and
57 // alter an adjacent provider-excluded contract.
58 func parseToolResultForApp(res json.RawMessage) (string, []string, error) {
59 return parseToolResultProjection(res, false)
60 }
61
62 func parseToolResultProjection(res json.RawMessage, includeRich bool) (string, []string, error) {
63 var out struct {
64 Content []json.RawMessage `json:"content"`
65 StructuredContent json.RawMessage `json:"structuredContent"`
66 IsError bool `json:"isError"`
67 }
68 if err := json.Unmarshal(res, &out); err != nil {
69 return "", nil, fmt.Errorf("decode tool result: %w", err)
70 }
71 var sb strings.Builder
72 var images []string
73 projection := newToolResultProjection()
74 for _, raw := range out.Content {
75 var header struct {
76 Type string `json:"type"`
77 }
78 if err := json.Unmarshal(raw, &header); err != nil {
79 return "", nil, fmt.Errorf("decode tool result content: %w", err)
80 }
81 switch header.Type {
82 case "text":
83 var content struct {
84 Text string `json:"text"`
85 }
86 if err := json.Unmarshal(raw, &content); err != nil {
87 return "", nil, fmt.Errorf("decode tool result text content: %w", err)
88 }
89 projection.writeInline(&sb, content.Text)
90 case "image":
91 var content struct {
92 Data string `json:"data"`
93 MimeType string `json:"mimeType"`
94 }
95 if err := json.Unmarshal(raw, &content); err != nil {
96 return "", nil, fmt.Errorf("decode tool result image content: %w", err)
97 }
98 placeholder, url := toolResultImage(content.MimeType, content.Data, len(images))
99 projection.writeInline(&sb, placeholder)
100 if url != "" {
101 images = append(images, url)
102 }
103 case "audio":
104 if !includeRich {
105 continue
106 }
107 block, err := projectToolResultAudio(raw)
108 if err != nil {
109 return "", nil, err
110 }
111 projection.writeBlock(&sb, "audio", block, false)
112 case "resource_link":
113 if !includeRich {
114 continue
115 }
116 block, err := projectToolResultResourceLink(raw)
117 if err != nil {
118 return "", nil, err
119 }
120 projection.writeBlock(&sb, "resource link", block, false)
121 case "resource":
122 if !includeRich {
123 continue
124 }
125 block, url, err := projectToolResultEmbeddedResource(raw, len(images))
126 if err != nil {
127 return "", nil, err
128 }
129 projection.writeBlock(&sb, "embedded resource", block, true)
130 if url != "" {
131 images = append(images, url)
132 }
133 default:
134 if includeRich {
135 projection.writeBlock(&sb, "content block", projectUnknownToolResultContent(header.Type), false)
136 }
137 }
138 }
139 if includeRich && hasToolResultStructuredContent(out.StructuredContent) {
140 structured := bytes.TrimSpace(out.StructuredContent)
141 visible := strings.TrimSpace(sb.String())
142 switch {
143 case visible == "" && len(structured) <= maxToolResultRichItemBytes:
144 canonical, err := canonicalToolResultJSON(structured)
145 if err != nil {
146 return "", nil, fmt.Errorf("decode tool result structured content: %w", err)
147 }
148 var compact bytes.Buffer
149 if err := json.Compact(&compact, canonical); err != nil {
150 return "", nil, fmt.Errorf("compact tool result structured content: %w", err)
151 }
152 projection.writeInline(&sb, compact.String())
153 case visible != "" && toolResultJSONEqual(visible, structured):
154 // A text JSON block and structuredContent with the same value are one
155 // result, not two independent model observations.
156 default:
157 block, err := projectToolResultStructuredContent(structured)
158 if err != nil {
159 return "", nil, err
160 }
161 projection.writeBlock(&sb, "structured content", block, false)
162 }
163 }
164 text := sb.String()
165 if out.IsError {
166 return text, images, &mcpApplicationError{message: fmt.Sprintf("plugin tool reported error: %s", text)}
167 }
168 return text, images, nil
169 }
170
171 // toolResultImage validates one MCP image content item and returns its text
172 // placeholder plus the data URL to forward ("" when the item is dropped).
173 func toolResultImage(mime, data string, kept int) (placeholder, url string) {
174 if kept >= maxToolResultImages {
175 return "[image omitted: per-result image limit reached]", ""
176 }
177 mime = strings.ToLower(strings.TrimSpace(mime))
178 if mime == "" {
179 mime = "image/png"
180 }
181 if !toolResultImageMimes[mime] {
182 return "[image omitted: unsupported type " + mime + "]", ""
183 }
184 // Some servers wrap base64 in whitespace; vision APIs reject non-canonical
185 // payloads, so normalize before validating.
186 data = normalizeToolResultBase64(data)
187 if data == "" {
188 return "[image omitted: no data]", ""
189 }
190 if len(data) > maxToolResultImageBytes {
191 return fmt.Sprintf("[image omitted: %d bytes exceeds the %d-byte limit]", len(data), maxToolResultImageBytes), ""
192 }
193 if _, err := decodedToolResultBase64Bytes(data); err != nil {
194 return "[image omitted: invalid base64]", ""
195 }
196 return "[image: " + mime + "]", "data:" + mime + ";base64," + data
197 }
198
199 type toolResultProjection struct {
200 remaining int
201 hasOutput bool
202 endsWithNewline bool
203 separateNextText bool
204 }
205
206 func newToolResultProjection() *toolResultProjection {
207 return &toolResultProjection{remaining: maxToolResultRichProjectionBytes}
208 }
209
210 // writeInline preserves the historical concatenation of text and direct image
211 // placeholders. It adds a separator only after a rich block so the following
212 // ordinary text cannot be mistaken for resource metadata or JSON.
213 func (p *toolResultProjection) writeInline(sb *strings.Builder, text string) {
214 if p == nil || text == "" {
215 return
216 }
217 if p.separateNextText && p.hasOutput && !p.endsWithNewline {
218 sb.WriteByte('\n')
219 if p.remaining > 0 {
220 p.remaining--
221 }
222 }
223 sb.WriteString(text)
224 p.hasOutput = true
225 p.endsWithNewline = strings.HasSuffix(text, "\n")
226 p.separateNextText = false
227 }
228
229 // writeBlock appends one model-facing rich-content block. Atomic blocks such
230 // as JSON are replaced with a valid omission marker rather than byte-truncated;
231 // human-readable embedded text may be clipped at a UTF-8 boundary.
232 func (p *toolResultProjection) writeBlock(sb *strings.Builder, kind, block string, truncatable bool) {
233 if p == nil || block == "" || p.remaining <= 0 {
234 return
235 }
236 if len(block) > maxToolResultRichItemBytes {
237 if truncatable {
238 block = clipToolResultProjection(block, maxToolResultRichItemBytes)
239 } else {
240 block = toolResultProjectionOmission(kind, len(block), maxToolResultRichItemBytes, "per-item")
241 }
242 }
243
244 separatorBytes := 0
245 if p.hasOutput && !p.endsWithNewline {
246 separatorBytes = 1
247 }
248 available := p.remaining - separatorBytes
249 if available <= 0 {
250 return
251 }
252 if len(block) > available {
253 if truncatable {
254 block = clipToolResultProjection(block, available)
255 } else {
256 block = toolResultProjectionOmission(kind, len(block), available, "aggregate")
257 if len(block) > available {
258 block = clipToolResultProjection(block, available)
259 }
260 }
261 }
262 if block == "" {
263 return
264 }
265 if separatorBytes != 0 {
266 sb.WriteByte('\n')
267 p.remaining--
268 }
269 sb.WriteString(block)
270 p.remaining -= len(block)
271 p.hasOutput = true
272 p.endsWithNewline = strings.HasSuffix(block, "\n")
273 p.separateNextText = true
274 }
275
276 func toolResultProjectionOmission(kind string, size, limit int, scope string) string {
277 return fmt.Sprintf("[MCP %s omitted: %d bytes exceed the %d-byte %s projection limit]", kind, size, limit, scope)
278 }
279
280 func clipToolResultProjection(text string, limit int) string {
281 if limit <= 0 {
282 return ""
283 }
284 if len(text) <= limit {
285 return text
286 }
287 prefixBytes := limit
288 for {
289 prefix := validToolResultUTF8Prefix(text, prefixBytes)
290 suffix := fmt.Sprintf("\n[MCP projection truncated: %d bytes omitted]", len(text)-len(prefix))
291 if len(suffix) >= limit {
292 return validToolResultUTF8Prefix(suffix, limit)
293 }
294 allowedPrefix := limit - len(suffix)
295 if len(prefix) <= allowedPrefix {
296 return prefix + suffix
297 }
298 prefixBytes = allowedPrefix
299 }
300 }
301
302 func validToolResultUTF8Prefix(text string, limit int) string {
303 if limit <= 0 {
304 return ""
305 }
306 if len(text) <= limit {
307 return text
308 }
309 limit = min(limit, len(text))
310 for limit > 0 && !utf8.ValidString(text[:limit]) {
311 limit--
312 }
313 return text[:limit]
314 }
315
316 func hasToolResultStructuredContent(raw json.RawMessage) bool {
317 trimmed := bytes.TrimSpace(raw)
318 return len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null"))
319 }
320
321 func projectToolResultStructuredContent(raw json.RawMessage) (string, error) {
322 trimmed := bytes.TrimSpace(raw)
323 if len(trimmed) > maxToolResultRichItemBytes {
324 return toolResultProjectionOmission("structured content", len(trimmed), maxToolResultRichItemBytes, "per-item"), nil
325 }
326 canonical, err := canonicalToolResultJSON(trimmed)
327 if err != nil {
328 return "", fmt.Errorf("decode tool result structured content: %w", err)
329 }
330 return "[MCP structured content]\n" + string(canonical), nil
331 }
332
333 func canonicalToolResultJSON(raw json.RawMessage) ([]byte, error) {
334 decoder := json.NewDecoder(bytes.NewReader(raw))
335 decoder.UseNumber()
336 var value any
337 if err := decoder.Decode(&value); err != nil {
338 return nil, err
339 }
340 if err := decoder.Decode(&struct{}{}); err != io.EOF {
341 if err == nil {
342 return nil, fmt.Errorf("multiple JSON values")
343 }
344 return nil, err
345 }
346 return json.MarshalIndent(value, "", " ")
347 }
348
349 func toolResultJSONEqual(text string, structured json.RawMessage) bool {
350 left, err := canonicalToolResultJSON(json.RawMessage(text))
351 if err != nil {
352 return false
353 }
354 right, err := canonicalToolResultJSON(structured)
355 return err == nil && bytes.Equal(left, right)
356 }
357
358 func projectToolResultResourceLink(raw json.RawMessage) (string, error) {
359 var content struct {
360 URI string `json:"uri"`
361 Name string `json:"name"`
362 Title string `json:"title"`
363 Description string `json:"description"`
364 MimeType string `json:"mimeType"`
365 Size json.Number `json:"size"`
366 }
367 if err := json.Unmarshal(raw, &content); err != nil {
368 return "", fmt.Errorf("decode tool result resource link: %w", err)
369 }
370 metadata := struct {
371 URI string `json:"uri"`
372 Name string `json:"name,omitempty"`
373 Title string `json:"title,omitempty"`
374 Description string `json:"description,omitempty"`
375 MimeType string `json:"mimeType,omitempty"`
376 Size json.Number `json:"size,omitempty"`
377 }{content.URI, content.Name, content.Title, content.Description, content.MimeType, content.Size}
378 b, err := json.Marshal(metadata)
379 if err != nil {
380 return "", fmt.Errorf("encode tool result resource link: %w", err)
381 }
382 return "[MCP resource link] " + string(b), nil
383 }
384
385 func projectToolResultEmbeddedResource(raw json.RawMessage, keptImages int) (block, imageURL string, err error) {
386 var content struct {
387 Resource struct {
388 URI string `json:"uri"`
389 MimeType string `json:"mimeType"`
390 Text string `json:"text"`
391 Blob string `json:"blob"`
392 } `json:"resource"`
393 }
394 if err := json.Unmarshal(raw, &content); err != nil {
395 return "", "", fmt.Errorf("decode tool result embedded resource: %w", err)
396 }
397 metadata := struct {
398 URI string `json:"uri"`
399 MimeType string `json:"mimeType,omitempty"`
400 }{content.Resource.URI, content.Resource.MimeType}
401 b, err := json.Marshal(metadata)
402 if err != nil {
403 return "", "", fmt.Errorf("encode tool result embedded resource: %w", err)
404 }
405 header := "[MCP embedded resource] " + string(b)
406 if content.Resource.Text != "" {
407 maxTextBytes := maxToolResultRichItemBytes - len(header) - 1
408 text := clipToolResultProjection(content.Resource.Text, maxTextBytes)
409 return header + "\n" + text, "", nil
410 }
411 if content.Resource.Blob == "" {
412 return header + "\n[MCP embedded resource content omitted: no text or blob]", "", nil
413 }
414 mime := strings.ToLower(strings.TrimSpace(content.Resource.MimeType))
415 if strings.HasPrefix(mime, "image/") {
416 placeholder, imageURL := toolResultImage(mime, content.Resource.Blob, keptImages)
417 return header + "\n" + placeholder, imageURL, nil
418 }
419 return header + "\n" + projectToolResultBinary("binary resource", mime, content.Resource.Blob, "omitted"), "", nil
420 }
421
422 func projectToolResultAudio(raw json.RawMessage) (string, error) {
423 var content struct {
424 Data string `json:"data"`
425 MimeType string `json:"mimeType"`
426 }
427 if err := json.Unmarshal(raw, &content); err != nil {
428 return "", fmt.Errorf("decode tool result audio content: %w", err)
429 }
430 return projectToolResultBinary("audio", content.MimeType, content.Data, "omitted: no audio provider channel"), nil
431 }
432
433 func projectToolResultBinary(kind, mime, data, validStatus string) string {
434 mime = strings.ToLower(strings.TrimSpace(mime))
435 normalized := normalizeToolResultBase64(data)
436 summary := struct {
437 MimeType string `json:"mimeType,omitempty"`
438 EncodedBytes int `json:"encodedBytes"`
439 DecodedBytes *int64 `json:"decodedBytes,omitempty"`
440 Data string `json:"data"`
441 }{MimeType: mime, EncodedBytes: len(normalized), Data: validStatus}
442 switch {
443 case normalized == "":
444 summary.Data = "omitted: no data"
445 case len(normalized) > maxToolResultImageBytes:
446 summary.Data = fmt.Sprintf("omitted: exceeds %d-byte inline base64 limit", maxToolResultImageBytes)
447 default:
448 decodedBytes, err := decodedToolResultBase64Bytes(normalized)
449 if err != nil {
450 summary.Data = "omitted: invalid base64"
451 } else {
452 summary.DecodedBytes = &decodedBytes
453 }
454 }
455 b, err := json.Marshal(summary)
456 if err != nil {
457 return fmt.Sprintf("[MCP %s omitted: metadata encoding failed]", kind)
458 }
459 return "[MCP " + kind + "] " + string(b)
460 }
461
462 func normalizeToolResultBase64(data string) string {
463 return strings.Map(func(r rune) rune {
464 switch r {
465 case '\n', '\r', '\t', ' ':
466 return -1
467 }
468 return r
469 }, data)
470 }
471
472 func decodedToolResultBase64Bytes(data string) (int64, error) {
473 return io.Copy(io.Discard, base64.NewDecoder(base64.StdEncoding, strings.NewReader(data)))
474 }
475
476 func projectUnknownToolResultContent(contentType string) string {
477 if strings.TrimSpace(contentType) == "" {
478 contentType = "missing"
479 }
480 b, _ := json.Marshal(struct {
481 Type string `json:"type"`
482 }{contentType})
483 return "[unsupported MCP content block] " + string(b)
484 }
485
485 lines GO