package core import ( "fmt" "time" "unicode/utf8" "github.com/bnema/zut/packages/provider" ) const ( maxToolResultTextBytes = 32 * 1024 toolResultOmissionMarker = "[tool result content omitted]" staleToolResultMarker = "[stale tool result omitted; a newer workspace view supersedes it]" ) // projectToolResultMessages returns a copied provider-input view of msgs. // The byte cap is independent per result. Provenance pruning is the deliberate // exception: a newer workspace view can replace an older result, trading the // provider cache prefix from that point for removal of context known to be // stale. The durable transcript is never rewritten. Compaction owns the // aggregate transcript budget. func projectToolResultMessages(msgs []provider.Message) []provider.Message { projected := copyToolResultMessages(msgs) stale := staleToolResultIDs(projected) for i := range projected { for j, content := range projected[i].Content { result, ok := content.(provider.ToolResultBlock) if !ok { continue } if stale[result.CallID] { result.Content = []provider.Content{provider.TextBlock{Text: staleToolResultMarker}} projected[i].Content[j] = result continue } if toolResultTextBytes(result.Content) > 0 { result.Content, _ = projectToolResultContent(result.Content, maxToolResultTextBytes) } if result.Timing != nil { result.Content = append(result.Content, provider.TextBlock{Text: formatToolTiming(result.Timing)}) } projected[i].Content[j] = result } } return projected } // staleToolResultIDs computes a causal projection over successful tool // results. Re-reading an equivalent resource view supersedes the old read; // reading a discovered file supersedes the search result that found it; and a // mutation supersedes every earlier view of the affected resource. func staleToolResultIDs(msgs []provider.Message) map[string]bool { stale := make(map[string]bool) reads := make(map[provider.ResourceRef]string) discoveries := make(map[string]map[string]bool) remainingDiscoveries := make(map[string]int) for _, message := range msgs { for _, content := range message.Content { result, ok := content.(provider.ToolResultBlock) if !ok || result.IsError || result.CallID == "" { continue } for _, resource := range result.Context.Reads { if previous := reads[resource]; previous != "" { stale[previous] = true } reads[resource] = result.CallID for discovery := range discoveries[resource.Key] { remainingDiscoveries[discovery]-- if remainingDiscoveries[discovery] == 0 { stale[discovery] = true } } delete(discoveries, resource.Key) } for _, resource := range result.Context.Mutates { for view, previous := range reads { if view.Key == resource { if previous != result.CallID { stale[previous] = true } delete(reads, view) } } for discovery := range discoveries[resource] { remainingDiscoveries[discovery]-- if remainingDiscoveries[discovery] == 0 { stale[discovery] = true } } delete(discoveries, resource) } for _, resource := range result.Context.Discovers { if discoveries[resource] == nil { discoveries[resource] = make(map[string]bool) } if !discoveries[resource][result.CallID] { discoveries[resource][result.CallID] = true remainingDiscoveries[result.CallID]++ } } } } return stale } // projectProviderMessages creates the provider-only transcript view. The // timestamp and timing annotations are deliberately added to this copy so // the visible transcript and its persisted historical content remain intact. func projectProviderMessages(msgs []provider.Message) []provider.Message { projected := projectToolResultMessages(msgs) for i, message := range projected { if message.Role != provider.RoleUser || message.Time.IsZero() { continue } content := make([]provider.Content, 0, len(message.Content)+1) content = append(content, provider.TextBlock{Text: fmt.Sprintf("[message time: %s]", message.Time.Format(time.RFC3339))}) content = append(content, message.Content...) projected[i].Content = content } return projected } func formatToolTiming(timing *provider.ToolTiming) string { return fmt.Sprintf("[tool timing: started=%s completed=%s duration=%s]", timing.StartedAt.Format(time.RFC3339), timing.CompletedAt.Format(time.RFC3339), timing.Duration, ) } // copyToolResultMessages copies the slices that the projection can replace. // Content blocks are values, so sharing their immutable fields is safe; the // nested content slice of every ToolResultBlock is copied before projection. func copyToolResultMessages(msgs []provider.Message) []provider.Message { if msgs == nil { return nil } out := make([]provider.Message, len(msgs)) for i, message := range msgs { out[i] = message if message.Content != nil { out[i].Content = append([]provider.Content(nil), message.Content...) } if message.AddedToolNames != nil { out[i].AddedToolNames = append([]string(nil), message.AddedToolNames...) } if message.Meta != nil { out[i].Meta = make(map[string]string, len(message.Meta)) for key, value := range message.Meta { out[i].Meta[key] = value } } for j, content := range out[i].Content { if result, ok := content.(provider.ToolResultBlock); ok { result.Content = append([]provider.Content(nil), result.Content...) out[i].Content[j] = result } } } return out } func toolResultTextBytes(content []provider.Content) int { total := 0 for _, block := range content { if text, ok := block.(provider.TextBlock); ok { total += len(text.Text) } } return total } // projectToolResultContent projects one result's textual content to limit // bytes. Every truncated result receives an omission marker, even after the // aggregate content budget is exhausted. The returned count excludes that // marker so the budget covers retained tool output rather than its notices. // Non-text blocks are retained in their original order. func projectToolResultContent(content []provider.Content, limit int) ([]provider.Content, int) { textBytes := toolResultTextBytes(content) if textBytes <= limit { return append([]provider.Content(nil), content...), textBytes } out := make([]provider.Content, 0, len(content)+1) prefixRemaining := limit prefixUsed := 0 truncated := false for _, block := range content { text, ok := block.(provider.TextBlock) if !ok { out = append(out, block) continue } if truncated { continue } if len(text.Text) <= prefixRemaining { out = append(out, text) prefixRemaining -= len(text.Text) prefixUsed += len(text.Text) continue } text.Text = utf8Prefix(text.Text, prefixRemaining) if text.Text != "" { out = append(out, text) } prefixUsed += len(text.Text) truncated = true } out = append(out, provider.TextBlock{Text: toolResultOmissionMarker}) return out, prefixUsed } func utf8Prefix(text string, limit int) string { if limit <= 0 { return "" } if limit >= len(text) { return text } for limit > 0 && !utf8.RuneStart(text[limit]) { limit-- } return text[:limit] }