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