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