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