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)
15
16// projectToolResultMessages returns a copied provider-input view of msgs.
17// Each tool result is bounded independently. Its projection must depend only
18// on that result: retroactively shrinking older results when a new one arrives
19// mutates the prompt prefix and defeats provider prompt caching. Compaction
20// owns the aggregate transcript budget.
21func projectToolResultMessages(msgs []provider.Message) []provider.Message {
22 projected := copyToolResultMessages(msgs)
23
24 for i := range projected {
25 for j, content := range projected[i].Content {
26 result, ok := content.(provider.ToolResultBlock)
27 if !ok {
28 continue
29 }
30
31 if toolResultTextBytes(result.Content) > 0 {
32 result.Content, _ = projectToolResultContent(result.Content, maxToolResultTextBytes)
33 }
34 if result.Timing != nil {
35 result.Content = append(result.Content, provider.TextBlock{Text: formatToolTiming(result.Timing)})
36 }
37 projected[i].Content[j] = result
38 }
39 }
40
41 return projected
42}
43
44// projectProviderMessages creates the provider-only transcript view. The
45// timestamp and timing annotations are deliberately added to this copy so
46// the visible transcript and its persisted historical content remain intact.
47func projectProviderMessages(msgs []provider.Message) []provider.Message {
48 projected := projectToolResultMessages(msgs)
49 for i, message := range projected {
50 if message.Role != provider.RoleUser || message.Time.IsZero() {
51 continue
52 }
53 content := make([]provider.Content, 0, len(message.Content)+1)
54 content = append(content, provider.TextBlock{Text: fmt.Sprintf("[message time: %s]", message.Time.Format(time.RFC3339))})
55 content = append(content, message.Content...)
56 projected[i].Content = content
57 }
58 return projected
59}
60
61func formatToolTiming(timing *provider.ToolTiming) string {
62 return fmt.Sprintf("[tool timing: started=%s completed=%s duration=%s]",
63 timing.StartedAt.Format(time.RFC3339),
64 timing.CompletedAt.Format(time.RFC3339),
65 timing.Duration,
66 )
67}
68
69// copyToolResultMessages copies the slices that the projection can replace.
70// Content blocks are values, so sharing their immutable fields is safe; the
71// nested content slice of every ToolResultBlock is copied before projection.
72func copyToolResultMessages(msgs []provider.Message) []provider.Message {
73 if msgs == nil {
74 return nil
75 }
76 out := make([]provider.Message, len(msgs))
77 for i, message := range msgs {
78 out[i] = message
79 if message.Content != nil {
80 out[i].Content = append([]provider.Content(nil), message.Content...)
81 }
82 if message.AddedToolNames != nil {
83 out[i].AddedToolNames = append([]string(nil), message.AddedToolNames...)
84 }
85 if message.Meta != nil {
86 out[i].Meta = make(map[string]string, len(message.Meta))
87 for key, value := range message.Meta {
88 out[i].Meta[key] = value
89 }
90 }
91 for j, content := range out[i].Content {
92 if result, ok := content.(provider.ToolResultBlock); ok {
93 result.Content = append([]provider.Content(nil), result.Content...)
94 out[i].Content[j] = result
95 }
96 }
97 }
98 return out
99}
100
101func toolResultTextBytes(content []provider.Content) int {
102 total := 0
103 for _, block := range content {
104 if text, ok := block.(provider.TextBlock); ok {
105 total += len(text.Text)
106 }
107 }
108 return total
109}
110
111// projectToolResultContent projects one result's textual content to limit
112// bytes. Every truncated result receives an omission marker, even after the
113// aggregate content budget is exhausted. The returned count excludes that
114// marker so the budget covers retained tool output rather than its notices.
115// Non-text blocks are retained in their original order.
116func projectToolResultContent(content []provider.Content, limit int) ([]provider.Content, int) {
117 textBytes := toolResultTextBytes(content)
118 if textBytes <= limit {
119 return append([]provider.Content(nil), content...), textBytes
120 }
121
122 out := make([]provider.Content, 0, len(content)+1)
123 prefixRemaining := limit
124 prefixUsed := 0
125 truncated := false
126 for _, block := range content {
127 text, ok := block.(provider.TextBlock)
128 if !ok {
129 out = append(out, block)
130 continue
131 }
132 if truncated {
133 continue
134 }
135 if len(text.Text) <= prefixRemaining {
136 out = append(out, text)
137 prefixRemaining -= len(text.Text)
138 prefixUsed += len(text.Text)
139 continue
140 }
141
142 text.Text = utf8Prefix(text.Text, prefixRemaining)
143 if text.Text != "" {
144 out = append(out, text)
145 }
146 prefixUsed += len(text.Text)
147 truncated = true
148 }
149 out = append(out, provider.TextBlock{Text: toolResultOmissionMarker})
150 return out, prefixUsed
151}
152
153func utf8Prefix(text string, limit int) string {
154 if limit <= 0 {
155 return ""
156 }
157 if limit >= len(text) {
158 return text
159 }
160 for limit > 0 && !utf8.RuneStart(text[limit]) {
161 limit--
162 }
163 return text[:limit]
164}