1package core
2
3import (
4 "unicode/utf8"
5
6 "github.com/bnema/zut/packages/provider"
7)
8
9const (
10 maxToolResultTextBytes = 32 * 1024
11 maxToolResultTotalTextBytes = 128 * 1024
12 toolResultOmissionMarker = "[tool result content omitted]"
13)
14
15// projectToolResultMessages returns a copied provider-input view of msgs.
16// Text inside each tool result is bounded independently and then against the
17// total budget, with newer results receiving the remaining budget first.
18// Tool-result blocks and all non-text content remain in the projection so
19// tool-call/result pairing stays valid.
20func projectToolResultMessages(msgs []provider.Message) []provider.Message {
21 projected := copyToolResultMessages(msgs)
22 remaining := maxToolResultTotalTextBytes
23
24 for i := len(projected) - 1; i >= 0; i-- {
25 for j := len(projected[i].Content) - 1; j >= 0; j-- {
26 result, ok := projected[i].Content[j].(provider.ToolResultBlock)
27 if !ok {
28 continue
29 }
30
31 textBytes := toolResultTextBytes(result.Content)
32 if textBytes == 0 {
33 continue
34 }
35
36 limit := maxToolResultTextBytes
37 if remaining < limit {
38 limit = remaining
39 }
40 var retained int
41 result.Content, retained = projectToolResultContent(result.Content, limit)
42 projected[i].Content[j] = result
43 remaining -= retained
44 }
45 }
46
47 return projected
48}
49
50// copyToolResultMessages copies the slices that the projection can replace.
51// Content blocks are values, so sharing their immutable fields is safe; the
52// nested content slice of every ToolResultBlock is copied before projection.
53func copyToolResultMessages(msgs []provider.Message) []provider.Message {
54 if msgs == nil {
55 return nil
56 }
57 out := make([]provider.Message, len(msgs))
58 for i, message := range msgs {
59 out[i] = message
60 if message.Content != nil {
61 out[i].Content = append([]provider.Content(nil), message.Content...)
62 }
63 if message.AddedToolNames != nil {
64 out[i].AddedToolNames = append([]string(nil), message.AddedToolNames...)
65 }
66 if message.Meta != nil {
67 out[i].Meta = make(map[string]string, len(message.Meta))
68 for key, value := range message.Meta {
69 out[i].Meta[key] = value
70 }
71 }
72 for j, content := range out[i].Content {
73 if result, ok := content.(provider.ToolResultBlock); ok {
74 result.Content = append([]provider.Content(nil), result.Content...)
75 out[i].Content[j] = result
76 }
77 }
78 }
79 return out
80}
81
82func toolResultTextBytes(content []provider.Content) int {
83 total := 0
84 for _, block := range content {
85 if text, ok := block.(provider.TextBlock); ok {
86 total += len(text.Text)
87 }
88 }
89 return total
90}
91
92// projectToolResultContent projects one result's textual content to limit
93// bytes. Every truncated result receives an omission marker, even after the
94// aggregate content budget is exhausted. The returned count excludes that
95// marker so the budget covers retained tool output rather than its notices.
96// Non-text blocks are retained in their original order.
97func projectToolResultContent(content []provider.Content, limit int) ([]provider.Content, int) {
98 textBytes := toolResultTextBytes(content)
99 if textBytes <= limit {
100 return append([]provider.Content(nil), content...), textBytes
101 }
102
103 out := make([]provider.Content, 0, len(content)+1)
104 prefixRemaining := limit
105 prefixUsed := 0
106 truncated := false
107 for _, block := range content {
108 text, ok := block.(provider.TextBlock)
109 if !ok {
110 out = append(out, block)
111 continue
112 }
113 if truncated {
114 continue
115 }
116 if len(text.Text) <= prefixRemaining {
117 out = append(out, text)
118 prefixRemaining -= len(text.Text)
119 prefixUsed += len(text.Text)
120 continue
121 }
122
123 text.Text = utf8Prefix(text.Text, prefixRemaining)
124 if text.Text != "" {
125 out = append(out, text)
126 }
127 prefixUsed += len(text.Text)
128 truncated = true
129 }
130 out = append(out, provider.TextBlock{Text: toolResultOmissionMarker})
131 return out, prefixUsed
132}
133
134func utf8Prefix(text string, limit int) string {
135 if limit <= 0 {
136 return ""
137 }
138 if limit >= len(text) {
139 return text
140 }
141 for limit > 0 && !utf8.RuneStart(text[limit]) {
142 limit--
143 }
144 return text[:limit]
145}