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 toolResultOmissionMarker = "[tool result content omitted]"
14 staleToolResultMarker = "[stale tool result omitted; a newer workspace view supersedes it]"
15)
16
17// projectToolResultMessages returns a copied provider-input view of msgs.
18// The byte cap is independent per result. Provenance pruning is the deliberate
19// exception: a newer workspace view can replace an older result, trading the
20// provider cache prefix from that point for removal of context known to be
21// stale. The durable transcript is never rewritten. Compaction owns the
22// aggregate transcript budget.
23func projectToolResultMessages(msgs []provider.Message) []provider.Message {
24 projected := copyToolResultMessages(msgs)
25 stale := staleToolResultIDs(projected)
26
27 for i := range projected {
28 for j, content := range projected[i].Content {
29 result, ok := content.(provider.ToolResultBlock)
30 if !ok {
31 continue
32 }
33 if stale[result.CallID] {
34 result.Content = []provider.Content{provider.TextBlock{Text: staleToolResultMarker}}
35 projected[i].Content[j] = result
36 continue
37 }
38
39 if toolResultTextBytes(result.Content) > 0 {
40 result.Content, _ = projectToolResultContent(result.Content, maxToolResultTextBytes)
41 }
42 if result.Timing != nil {
43 result.Content = append(result.Content, provider.TextBlock{Text: formatToolTiming(result.Timing)})
44 }
45 projected[i].Content[j] = result
46 }
47 }
48
49 return projected
50}
51
52// staleToolResultIDs computes a causal projection over successful tool
53// results. Re-reading an equivalent resource view supersedes the old read;
54// reading a discovered file supersedes the search result that found it; and a
55// mutation supersedes every earlier view of the affected resource.
56func staleToolResultIDs(msgs []provider.Message) map[string]bool {
57 stale := make(map[string]bool)
58 reads := make(map[provider.ResourceRef]string)
59 discoveries := make(map[string]map[string]bool)
60 remainingDiscoveries := make(map[string]int)
61 for _, message := range msgs {
62 for _, content := range message.Content {
63 result, ok := content.(provider.ToolResultBlock)
64 if !ok || result.IsError || result.CallID == "" {
65 continue
66 }
67 for _, resource := range result.Context.Reads {
68 if previous := reads[resource]; previous != "" {
69 stale[previous] = true
70 }
71 reads[resource] = result.CallID
72 for discovery := range discoveries[resource.Key] {
73 remainingDiscoveries[discovery]--
74 if remainingDiscoveries[discovery] == 0 {
75 stale[discovery] = true
76 }
77 }
78 delete(discoveries, resource.Key)
79 }
80 for _, resource := range result.Context.Mutates {
81 for view, previous := range reads {
82 if view.Key == resource {
83 if previous != result.CallID {
84 stale[previous] = true
85 }
86 delete(reads, view)
87 }
88 }
89 for discovery := range discoveries[resource] {
90 remainingDiscoveries[discovery]--
91 if remainingDiscoveries[discovery] == 0 {
92 stale[discovery] = true
93 }
94 }
95 delete(discoveries, resource)
96 }
97 for _, resource := range result.Context.Discovers {
98 if discoveries[resource] == nil {
99 discoveries[resource] = make(map[string]bool)
100 }
101 if !discoveries[resource][result.CallID] {
102 discoveries[resource][result.CallID] = true
103 remainingDiscoveries[result.CallID]++
104 }
105 }
106 }
107 }
108 return stale
109}
110
111// projectProviderMessages creates the provider-only transcript view. The
112// timestamp and timing annotations are deliberately added to this copy so
113// the visible transcript and its persisted historical content remain intact.
114func projectProviderMessages(msgs []provider.Message) []provider.Message {
115 projected := projectToolResultMessages(msgs)
116 for i, message := range projected {
117 if message.Role != provider.RoleUser || message.Time.IsZero() {
118 continue
119 }
120 content := make([]provider.Content, 0, len(message.Content)+1)
121 content = append(content, provider.TextBlock{Text: fmt.Sprintf("[message time: %s]", message.Time.Format(time.RFC3339))})
122 content = append(content, message.Content...)
123 projected[i].Content = content
124 }
125 return projected
126}
127
128func formatToolTiming(timing *provider.ToolTiming) string {
129 return fmt.Sprintf("[tool timing: started=%s completed=%s duration=%s]",
130 timing.StartedAt.Format(time.RFC3339),
131 timing.CompletedAt.Format(time.RFC3339),
132 timing.Duration,
133 )
134}
135
136// copyToolResultMessages copies the slices that the projection can replace.
137// Content blocks are values, so sharing their immutable fields is safe; the
138// nested content slice of every ToolResultBlock is copied before projection.
139func copyToolResultMessages(msgs []provider.Message) []provider.Message {
140 if msgs == nil {
141 return nil
142 }
143 out := make([]provider.Message, len(msgs))
144 for i, message := range msgs {
145 out[i] = message
146 if message.Content != nil {
147 out[i].Content = append([]provider.Content(nil), message.Content...)
148 }
149 if message.AddedToolNames != nil {
150 out[i].AddedToolNames = append([]string(nil), message.AddedToolNames...)
151 }
152 if message.Meta != nil {
153 out[i].Meta = make(map[string]string, len(message.Meta))
154 for key, value := range message.Meta {
155 out[i].Meta[key] = value
156 }
157 }
158 for j, content := range out[i].Content {
159 if result, ok := content.(provider.ToolResultBlock); ok {
160 result.Content = append([]provider.Content(nil), result.Content...)
161 out[i].Content[j] = result
162 }
163 }
164 }
165 return out
166}
167
168func toolResultTextBytes(content []provider.Content) int {
169 total := 0
170 for _, block := range content {
171 if text, ok := block.(provider.TextBlock); ok {
172 total += len(text.Text)
173 }
174 }
175 return total
176}
177
178// projectToolResultContent projects one result's textual content to limit
179// bytes. Every truncated result receives an omission marker, even after the
180// aggregate content budget is exhausted. The returned count excludes that
181// marker so the budget covers retained tool output rather than its notices.
182// Non-text blocks are retained in their original order.
183func projectToolResultContent(content []provider.Content, limit int) ([]provider.Content, int) {
184 textBytes := toolResultTextBytes(content)
185 if textBytes <= limit {
186 return append([]provider.Content(nil), content...), textBytes
187 }
188
189 out := make([]provider.Content, 0, len(content)+1)
190 prefixRemaining := limit
191 prefixUsed := 0
192 truncated := false
193 for _, block := range content {
194 text, ok := block.(provider.TextBlock)
195 if !ok {
196 out = append(out, block)
197 continue
198 }
199 if truncated {
200 continue
201 }
202 if len(text.Text) <= prefixRemaining {
203 out = append(out, text)
204 prefixRemaining -= len(text.Text)
205 prefixUsed += len(text.Text)
206 continue
207 }
208
209 text.Text = utf8Prefix(text.Text, prefixRemaining)
210 if text.Text != "" {
211 out = append(out, text)
212 }
213 prefixUsed += len(text.Text)
214 truncated = true
215 }
216 out = append(out, provider.TextBlock{Text: toolResultOmissionMarker})
217 return out, prefixUsed
218}
219
220func utf8Prefix(text string, limit int) string {
221 if limit <= 0 {
222 return ""
223 }
224 if limit >= len(text) {
225 return text
226 }
227 for limit > 0 && !utf8.RuneStart(text[limit]) {
228 limit--
229 }
230 return text[:limit]
231}