1package core
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9 "sync"
10 "time"
11
12 "github.com/patriceckhart/zot/packages/provider"
13)
14
15// Agent is a stateful conversation bound to a provider client, a model,
16// and a set of tools.
17type Agent struct {
18 Client provider.Client
19 Model string
20 System string
21 Tools Registry
22 MaxSteps int
23 Reasoning string
24
25 // BeforeToolExecute, if set, is called immediately before each
26 // tool runs. Returning (allowed=false, reason) short-circuits
27 // the call with an error result containing reason. Optionally,
28 // returning a non-nil modifiedArgs replaces the JSON args the
29 // tool will see, which lets guards redact / augment / patch the
30 // model's request without rewriting the transcript. Empty or
31 // malformed modifiedArgs is ignored.
32 BeforeToolExecute func(call provider.ToolCallBlock) (allowed bool, reason string, modifiedArgs json.RawMessage)
33
34 // BeforeTurn, if set, is called before each turn's model call.
35 // Returning (allowed=false, reason) aborts the turn; reason is
36 // surfaced as an assistant-like status line. Used for rate-
37 // limiting, business-hour gates, and deny-by-default setups.
38 BeforeTurn func(step int) (allowed bool, reason string)
39
40 // BeforeAssistantMessage, if set, is called after the model's
41 // final assistant message is assembled but before it's appended
42 // to the transcript. Returning (allowed=false) suppresses both
43 // the transcript append and the UI event. A non-empty
44 // replacement rewrites the visible text for the user while
45 // leaving the model's original text in the transcript (so the
46 // model can still see what it said in subsequent turns).
47 BeforeAssistantMessage func(text string) (allowed bool, reason, replacement string)
48
49 // MaxRetries controls agent-level retries for transient provider
50 // failures that arrive after the HTTP stream opens (for example
51 // Anthropic overloaded_error). Zero disables this retry layer.
52 // RetryBaseDelay is doubled for each attempt; zero uses 2s.
53 MaxRetries int
54 RetryBaseDelay time.Duration
55
56 // OnEvent, if set, mirrors every AgentEvent the loop emits to
57 // this callback in addition to the per-Prompt sink. Used by the
58 // extension manager to fan events out to subscribed extensions
59 // without each caller having to compose sinks manually.
60 OnEvent func(AgentEvent)
61
62 // OnMessageAppended, if set, fires every time a message is
63 // appended to the in-memory transcript by the agent loop — the
64 // initial user prompt, each finalised assistant message, and
65 // each tool-results message (plus the synthetic OpenAI image
66 // mirror, if any). Hosts wire this to the on-disk session so
67 // that turns are durable as soon as they happen, instead of
68 // only being flushed on a clean exit.
69 OnMessageAppended func(provider.Message)
70
71 // OnUsage, if set, fires after every turn's usage row arrives,
72 // carrying the cumulative usage for the session. Hosts wire
73 // this to the on-disk session so the persisted total stays
74 // current and a crash recovers the right cost figure.
75 OnUsage func(cumulative provider.Usage)
76
77 // OnTranscriptCompacted, if set, fires after Compact replaces the
78 // in-memory transcript with the synthetic summary plus kept tail.
79 // Hosts wire this to append an explicit compaction checkpoint to
80 // the session log; per-message append hooks do not fire for this
81 // wholesale transcript replacement.
82 OnTranscriptCompacted func(messages []provider.Message)
83
84 mu sync.Mutex
85 messages []provider.Message
86 // rev increments whenever the transcript slice is replaced or a
87 // message is appended. The TUI uses it as a cheap redraw cache key
88 // so editor-only typing doesn't copy/rebuild a long transcript on
89 // every keypress.
90 rev uint64
91 cost CostTracker
92
93 // queued holds user messages submitted while the agent is busy.
94 // The loop appends them as normal user messages at safe
95 // boundaries: before the next model call after a tool batch, or
96 // after a text-only assistant turn finishes. It never interrupts
97 // a running tool or cancels an in-flight provider request.
98 queued []string
99}
100
101// NewAgent returns an Agent with sensible defaults.
102func NewAgent(client provider.Client, model, system string, tools Registry) *Agent {
103 return &Agent{
104 Client: client,
105 Model: model,
106 System: system,
107 Tools: tools,
108 MaxSteps: 0, // 0 = unlimited
109 MaxRetries: 3,
110 RetryBaseDelay: 2 * time.Second,
111 }
112}
113
114// QueueMessage queues text to be injected as a user message at the
115// next safe boundary of the active agent loop. It is non-blocking in
116// the sense that it never waits for model/tool work; it only takes
117// the transcript mutex briefly. Empty/whitespace-only messages are
118// ignored.
119func (a *Agent) QueueMessage(text string) bool {
120 text = strings.TrimSpace(text)
121 if text == "" {
122 return false
123 }
124 a.mu.Lock()
125 a.queued = append(a.queued, text)
126 a.mu.Unlock()
127 return true
128}
129
130// PendingQueuedMessages returns a snapshot of user messages waiting
131// to be injected. Used by hosts to render the visible "sliding in"
132// chips without consuming them.
133func (a *Agent) PendingQueuedMessages() []string {
134 a.mu.Lock()
135 defer a.mu.Unlock()
136 out := make([]string, len(a.queued))
137 copy(out, a.queued)
138 return out
139}
140
141// QueuedMessageCount returns the number of messages waiting to be
142// injected at the next safe boundary.
143func (a *Agent) QueuedMessageCount() int {
144 a.mu.Lock()
145 defer a.mu.Unlock()
146 return len(a.queued)
147}
148
149// PopQueuedMessage removes and returns the most recently queued
150// message. Hosts use this for the slide-back keybinding.
151func (a *Agent) PopQueuedMessage() (string, bool) {
152 a.mu.Lock()
153 defer a.mu.Unlock()
154 n := len(a.queued)
155 if n == 0 {
156 return "", false
157 }
158 text := a.queued[n-1]
159 a.queued = a.queued[:n-1]
160 return text, true
161}
162
163// DrainQueuedMessages discards and returns every queued message.
164// Hosts use this on explicit cancel/clear so stale follow-ups do
165// not run after the user aborted the turn.
166func (a *Agent) DrainQueuedMessages() []string {
167 return a.drainQueuedMessages()
168}
169
170func (a *Agent) drainQueuedMessages() []string {
171 a.mu.Lock()
172 defer a.mu.Unlock()
173 out := make([]string, len(a.queued))
174 copy(out, a.queued)
175 a.queued = nil
176 return out
177}
178
179func (a *Agent) appendQueuedAsUser(texts []string, sink func(AgentEvent)) {
180 for _, text := range texts {
181 msg := provider.Message{
182 Role: provider.RoleUser,
183 Content: []provider.Content{provider.TextBlock{Text: text}},
184 Time: time.Now(),
185 }
186 a.mu.Lock()
187 a.messages = append(a.messages, msg)
188 a.rev++
189 a.mu.Unlock()
190 a.fireMessageAppended(msg)
191 if sink != nil {
192 sink(EvUserMessage{Message: msg})
193 }
194 }
195}
196
197// Messages returns a copy of the current transcript.
198func (a *Agent) Messages() []provider.Message {
199 a.mu.Lock()
200 defer a.mu.Unlock()
201 out := make([]provider.Message, len(a.messages))
202 copy(out, a.messages)
203 return out
204}
205
206// Revision returns a monotonically increasing transcript version.
207// It is cheap to query and changes whenever Messages() would return
208// different transcript content because of append/set operations.
209func (a *Agent) Revision() uint64 {
210 a.mu.Lock()
211 defer a.mu.Unlock()
212 return a.rev
213}
214
215// SetTools swaps the tool registry. Used by /reload-ext to hand
216// the agent a fresh registry after extension subprocesses have been
217// respawned (and their freshly-registered tools merged in).
218func (a *Agent) SetTools(reg Registry) {
219 a.mu.Lock()
220 a.Tools = reg
221 a.mu.Unlock()
222}
223
224// SetMessages replaces the transcript (used when resuming a session).
225func (a *Agent) SetMessages(msgs []provider.Message) {
226 a.mu.Lock()
227 defer a.mu.Unlock()
228 a.messages = append(a.messages[:0], msgs...)
229 a.rev++
230}
231
232// Cost returns the cumulative usage.
233func (a *Agent) Cost() provider.Usage {
234 a.mu.Lock()
235 defer a.mu.Unlock()
236 return a.cost.Total
237}
238
239// SeedCost sets the cumulative usage as a baseline before the first
240// turn runs. Used when transferring state from another agent (model
241// or provider switch) so the running cost meter doesn't reset to 0.
242func (a *Agent) SeedCost(u provider.Usage) {
243 a.mu.Lock()
244 defer a.mu.Unlock()
245 a.cost.Total = u
246}
247
248// LastTurnUsage returns the per-turn usage of the most recent
249// completed turn. Drives the "context used" gauge in the status bar
250// without waiting for the next turn to land.
251func (a *Agent) LastTurnUsage() provider.Usage {
252 a.mu.Lock()
253 defer a.mu.Unlock()
254 return a.cost.LastTurn
255}
256
257// SeedLastTurnUsage primes the per-turn snapshot. Used on resume so
258// the gauge reflects the prompt size of the last turn in the session
259// file instead of starting at zero.
260func (a *Agent) SeedLastTurnUsage(u provider.Usage) {
261 a.mu.Lock()
262 defer a.mu.Unlock()
263 a.cost.LastTurn = u
264}
265
266// fireMessageAppended invokes OnMessageAppended without holding the
267// agent mutex, so the host's persistence callback can take its own
268// locks without deadlocking the agent loop. Tolerates a nil hook so
269// non-persisting callers (tests, RPC mode) don't have to set it.
270func (a *Agent) fireMessageAppended(m provider.Message) {
271 if a.OnMessageAppended != nil {
272 a.OnMessageAppended(m)
273 }
274}
275
276// Prompt sends a user message and runs the agent loop until the model
277// stops or an error occurs. Events are delivered via sink in order.
278// sink must not block the caller for long; buffer as needed.
279func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error {
280 if sink == nil {
281 sink = func(AgentEvent) {}
282 }
283 sink = a.wrapSink(sink)
284 content := []provider.Content{}
285 if text != "" {
286 content = append(content, provider.TextBlock{Text: text})
287 }
288 for _, img := range images {
289 content = append(content, img)
290 }
291 user := provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}
292
293 a.mu.Lock()
294 a.messages = append(a.messages, user)
295 a.rev++
296 a.mu.Unlock()
297 a.fireMessageAppended(user)
298 sink(EvUserMessage{Message: user})
299
300 return a.runLoop(ctx, sink)
301}
302
303// Continue runs the agent loop against the existing transcript. Used
304// after appending tool results manually or to retry.
305func (a *Agent) Continue(ctx context.Context, sink func(AgentEvent)) error {
306 if sink == nil {
307 sink = func(AgentEvent) {}
308 }
309 sink = a.wrapSink(sink)
310 return a.runLoop(ctx, sink)
311}
312
313// wrapSink composes the per-call sink with a.OnEvent (if set) so the
314// extension manager (or any other observer) sees every AgentEvent
315// without having to thread itself through every Prompt callsite.
316func (a *Agent) wrapSink(sink func(AgentEvent)) func(AgentEvent) {
317 if a.OnEvent == nil {
318 return sink
319 }
320 obs := a.OnEvent
321 return func(ev AgentEvent) {
322 obs(ev)
323 sink(ev)
324 }
325}
326
327func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
328 for step := 1; a.MaxSteps <= 0 || step <= a.MaxSteps; step++ {
329 // Messages queued while the agent was busy are delivered
330 // before the next model call. This is the safe boundary:
331 // any previous tool batch has already completed and its
332 // results have been appended, but no new provider request has
333 // started yet.
334 if pending := a.drainQueuedMessages(); len(pending) > 0 {
335 a.appendQueuedAsUser(pending, sink)
336 }
337
338 sink(EvTurnStart{Step: step})
339 if a.BeforeTurn != nil {
340 if allowed, reason := a.BeforeTurn(step); !allowed {
341 if reason == "" {
342 reason = "turn blocked by extension guard"
343 }
344 sink(EvTurnEnd{Stop: provider.StopError, Err: fmt.Errorf("%s", reason)})
345 sink(EvDone{})
346 return nil
347 }
348 }
349
350 var (
351 stop provider.StopReason
352 assistantMsg provider.Message
353 err error
354 )
355 for attempt := 0; ; attempt++ {
356 stop, assistantMsg, err = a.oneTurn(ctx, sink)
357 sink(EvTurnEnd{Stop: stop, Err: err})
358 if err == nil || !a.canRetryError(err, attempt) {
359 break
360 }
361 a.dropLastAssistantMessage()
362 if sleepErr := sleepRetry(ctx, a.retryDelay(attempt)); sleepErr != nil {
363 return sleepErr
364 }
365 }
366 if err != nil {
367 return err
368 }
369
370 if stop == provider.StopToolUse {
371 // Execute each tool call, append a single tool-results message, continue.
372 toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink)
373 a.mu.Lock()
374 a.messages = append(a.messages, toolMsg)
375 a.rev++
376 // OpenAI's chat-completions tool message shape is text-centric.
377 // Vision models reliably consume images when they arrive as user
378 // content, so when a tool result contains images we mirror them
379 // into a synthetic user message immediately after the tool result.
380 // This keeps the transcript self-contained for providers that can
381 // see image blocks in tool messages while making OpenAI vision
382 // models actually receive the image bytes.
383 //
384 // The OpenAI Responses route ("openai-codex") has the same
385 // text-centric tool-output shape: a function_call_output only
386 // carries a string, so images in a tool result never reach the
387 // model. Both providers serialize images correctly when they
388 // arrive as user content, so the mirror covers them both.
389 var imageMirror provider.Message
390 if a.Client != nil && (a.Client.Name() == "openai" || a.Client.Name() == "openai-codex") {
391 if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 {
392 a.messages = append(a.messages, mirror)
393 a.rev++
394 imageMirror = mirror
395 }
396 }
397 a.mu.Unlock()
398 a.fireMessageAppended(toolMsg)
399 if len(imageMirror.Content) > 0 {
400 a.fireMessageAppended(imageMirror)
401 }
402 // If context was cancelled during tool execution, bail out.
403 if err := ctx.Err(); err != nil {
404 sink(EvDone{})
405 return err
406 }
407 _ = hadError
408 continue
409 }
410
411 // If the assistant stopped without tool calls but a message was
412 // queued while it was speaking, loop once more so that message
413 // is appended and answered instead of waiting until a later
414 // top-level prompt.
415 if ctx.Err() == nil && a.QueuedMessageCount() > 0 {
416 continue
417 }
418
419 // Terminal stop (end, length, error, aborted).
420 sink(EvDone{})
421 return nil
422 }
423 if a.MaxSteps > 0 {
424 sink(EvDone{})
425 return fmt.Errorf("max steps (%d) exceeded", a.MaxSteps)
426 }
427 return nil
428}
429
430func (a *Agent) canRetryError(err error, attempt int) bool {
431 if err == nil || a.MaxRetries <= 0 || attempt >= a.MaxRetries {
432 return false
433 }
434 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
435 return false
436 }
437 msg := strings.ToLower(err.Error())
438 if msg == "" || isNonRetryableProviderLimit(msg) {
439 return false
440 }
441 needles := []string{
442 "overloaded", "provider returned error", "rate limit", "ratelimit", "too many requests",
443 "429", "http 429", "500", "http 500", "502", "http 502", "503", "http 503", "504", "http 504",
444 "service unavailable", "server error", "internal error", "network error", "connection error",
445 "connection refused", "connection lost", "fetch failed", "upstream connect", "reset before headers",
446 "socket hang up", "ended without", "stream ended before", "did not get a response", "timed out",
447 "timeout", "terminated", "unexpected eof", "transport failure",
448 }
449 for _, needle := range needles {
450 if strings.Contains(msg, needle) {
451 return true
452 }
453 }
454 return false
455}
456
457func isNonRetryableProviderLimit(msg string) bool {
458 needles := []string{
459 "usage limit", "monthly usage limit", "freeusagelimit", "gousagelimit",
460 "available balance", "insufficient_quota", "out of budget", "quota exceeded", "billing",
461 }
462 for _, needle := range needles {
463 if strings.Contains(msg, needle) {
464 return true
465 }
466 }
467 return false
468}
469
470func (a *Agent) retryDelay(attempt int) time.Duration {
471 base := a.RetryBaseDelay
472 if base <= 0 {
473 base = 2 * time.Second
474 }
475 return base * time.Duration(1<<attempt)
476}
477
478func sleepRetry(ctx context.Context, d time.Duration) error {
479 if d <= 0 {
480 return ctx.Err()
481 }
482 t := time.NewTimer(d)
483 defer t.Stop()
484 select {
485 case <-ctx.Done():
486 return ctx.Err()
487 case <-t.C:
488 return nil
489 }
490}
491
492func (a *Agent) dropLastAssistantMessage() {
493 a.mu.Lock()
494 defer a.mu.Unlock()
495 if n := len(a.messages); n > 0 && a.messages[n-1].Role == provider.RoleAssistant {
496 a.messages = a.messages[:n-1]
497 a.rev++
498 }
499}
500
501// oneTurn calls the LLM once, forwards events, returns the stop reason
502// and the assembled assistant message (already appended to the transcript).
503func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) {
504 req := provider.Request{
505 Model: a.Model,
506 System: a.System,
507 Messages: a.Messages(),
508 Tools: a.Tools.Specs(),
509 Reasoning: a.Reasoning,
510 }
511 stream, err := a.Client.Stream(ctx, req)
512 if err != nil {
513 return provider.StopError, provider.Message{}, err
514 }
515
516 sink(EvAssistantStart{})
517
518 var (
519 stop provider.StopReason
520 finalErr error
521 finalMsg provider.Message
522 )
523
524 for ev := range stream {
525 switch e := ev.(type) {
526 case provider.EventStart:
527 // nothing
528 case provider.EventTextDelta:
529 sink(EvTextDelta{Delta: e.Delta})
530 case provider.EventToolStart:
531 sink(EvToolUseStart{ID: e.ID, Name: e.Name})
532 case provider.EventToolArgs:
533 sink(EvToolUseArgs{ID: e.ID, Delta: e.Delta})
534 case provider.EventToolEnd:
535 sink(EvToolUseEnd{ID: e.ID})
536 case provider.EventUsage:
537 cum := a.cost.Add(e.Usage)
538 sink(EvUsage{Usage: e.Usage, Cumulative: cum})
539 if a.OnUsage != nil {
540 a.OnUsage(cum)
541 }
542 case provider.EventDone:
543 stop = e.Stop
544 finalErr = e.Err
545 finalMsg = e.Message
546 }
547 }
548
549 // Append assistant message to transcript. Aborted turns (Esc / Ctrl+C)
550 // produce partial content. When the partial message is text only we
551 // keep whatever was streamed up to the cancel so the user does not
552 // lose visible work (a cut-off summary is still useful). If the
553 // partial message already contained tool-call blocks we drop the
554 // whole thing, because an unmatched tool_use would fail the next
555 // turn with a tool_result mismatch error.
556 keep := len(finalMsg.Content) > 0
557 if stop == provider.StopAborted && keep {
558 hasToolCall := false
559 for _, c := range finalMsg.Content {
560 if _, ok := c.(provider.ToolCallBlock); ok {
561 hasToolCall = true
562 break
563 }
564 }
565 if hasToolCall {
566 keep = false
567 }
568 }
569 if keep {
570 emit := finalMsg
571 suppress := false
572
573 // BeforeAssistantMessage hook: extensions can suppress or
574 // rewrite the visible text. The transcript keeps the
575 // model's original output so the model still sees what it
576 // said on subsequent turns.
577 if a.BeforeAssistantMessage != nil {
578 orig := extractText(finalMsg)
579 if orig != "" {
580 allowed, _, replacement := a.BeforeAssistantMessage(orig)
581 if !allowed {
582 suppress = true
583 } else if replacement != "" && replacement != orig {
584 emit = replaceText(finalMsg, replacement)
585 }
586 }
587 }
588
589 a.mu.Lock()
590 a.messages = append(a.messages, finalMsg)
591 a.rev++
592 a.mu.Unlock()
593 a.fireMessageAppended(finalMsg)
594 if !suppress {
595 sink(EvAssistantMessage{Message: emit})
596 }
597 // Now surface tool calls as EvToolCall events so UIs can render them
598 // in order before the tool results arrive.
599 for _, c := range finalMsg.Content {
600 if tc, ok := c.(provider.ToolCallBlock); ok {
601 sink(EvToolCall{ID: tc.ID, Name: tc.Name, Args: tc.Arguments})
602 }
603 }
604 }
605
606 return stop, finalMsg, finalErr
607}
608
609// executeTools runs every tool call in the assistant message and returns
610// a single tool-role message carrying all results.
611func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) {
612 var results []provider.Content
613 hadError := false
614
615 for _, c := range msg.Content {
616 tc, ok := c.(provider.ToolCallBlock)
617 if !ok {
618 continue
619 }
620 res := a.runOneTool(ctx, tc, sink)
621 if res.IsError {
622 hadError = true
623 }
624 results = append(results, provider.ToolResultBlock{
625 CallID: tc.ID,
626 Content: res.Content,
627 IsError: res.IsError,
628 })
629 sink(EvToolResult{ID: tc.ID, Result: res})
630 }
631
632 return provider.Message{
633 Role: provider.RoleTool,
634 Content: results,
635 Time: time.Now(),
636 }, hadError
637}
638
639func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult {
640 tool, err := a.Tools.Get(tc.Name)
641 if err != nil {
642 return ToolResult{
643 Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
644 IsError: true,
645 }
646 }
647
648 args := tc.Arguments
649
650 // Intercept hook: an extension or other guard can refuse the
651 // call before any side effect happens, OR rewrite the args
652 // seen by the tool. The model sees the reason as the tool
653 // error, learns from it, and (typically) proposes a different
654 // action; rewrites are invisible to the model (they apply only
655 // to the execution).
656 if a.BeforeToolExecute != nil {
657 allowed, reason, modified := a.BeforeToolExecute(tc)
658 if !allowed {
659 if reason == "" {
660 reason = "tool call refused by extension guard"
661 }
662 return ToolResult{
663 Content: []provider.Content{provider.TextBlock{Text: reason}},
664 IsError: true,
665 }
666 }
667 if len(modified) > 0 && json.Valid(modified) {
668 args = modified
669 }
670 }
671
672 if len(args) == 0 {
673 args = json.RawMessage("{}")
674 }
675
676 // Recover panics so a buggy tool does not crash the agent.
677 var res ToolResult
678 func() {
679 defer func() {
680 if r := recover(); r != nil {
681 res = ToolResult{
682 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("panic: %v", r)}},
683 IsError: true,
684 }
685 }
686 }()
687 out, err := tool.Execute(ctx, args, func(text string) {
688 sink(EvToolProgress{ID: tc.ID, Text: text})
689 })
690 if err != nil {
691 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
692 res = ToolResult{
693 Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}},
694 IsError: true,
695 }
696 return
697 }
698 res = ToolResult{
699 Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
700 IsError: true,
701 }
702 return
703 }
704 res = out
705 }()
706 return res
707}
708
709// extractText concatenates all TextBlock content in a message. Used
710// by BeforeAssistantMessage so guards see a single string instead of
711// having to walk provider.Content themselves.
712func mirrorToolImagesAsUser(msg provider.Message) provider.Message {
713 var content []provider.Content
714 for _, c := range msg.Content {
715 tr, ok := c.(provider.ToolResultBlock)
716 if !ok {
717 continue
718 }
719 for _, inner := range tr.Content {
720 switch v := inner.(type) {
721 case provider.TextBlock:
722 // Keep short textual context so the model understands why
723 // the images appeared, but don't duplicate giant read
724 // outputs verbatim.
725 if len(v.Text) > 0 && len(v.Text) <= 500 {
726 content = append(content, v)
727 }
728 case provider.ImageBlock:
729 content = append(content, v)
730 }
731 }
732 }
733 if len(content) == 0 {
734 return provider.Message{}
735 }
736 prefix := provider.TextBlock{Text: "Tool output included the following image content:"}
737 content = append([]provider.Content{prefix}, content...)
738 return provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}
739}
740
741func extractText(msg provider.Message) string {
742 var out string
743 for _, c := range msg.Content {
744 if tb, ok := c.(provider.TextBlock); ok {
745 if out != "" {
746 out += "\n"
747 }
748 out += tb.Text
749 }
750 }
751 return out
752}
753
754// replaceText returns a copy of msg with every TextBlock replaced by
755// a single TextBlock containing replacement. Non-text content (tool
756// calls, etc.) is preserved in order.
757func replaceText(msg provider.Message, replacement string) provider.Message {
758 out := provider.Message{Role: msg.Role}
759 out.Content = make([]provider.Content, 0, len(msg.Content))
760 replaced := false
761 for _, c := range msg.Content {
762 if _, ok := c.(provider.TextBlock); ok {
763 if !replaced {
764 out.Content = append(out.Content, provider.TextBlock{Text: replacement})
765 replaced = true
766 }
767 continue
768 }
769 out.Content = append(out.Content, c)
770 }
771 if !replaced {
772 out.Content = append(out.Content, provider.TextBlock{Text: replacement})
773 }
774 return out
775}