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 // Repair any dangling tool_use blocks before sending. A turn
508 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a
509 // dev server, etc.) can leave an assistant tool_use with no
510 // matching tool_result in the live transcript. The load-time
511 // repair in OpenSession only runs on restart, so without this the
512 // next in-process request is rejected by providers like Anthropic
513 // with "tool_use ids were found without tool_result blocks". The
514 // repair is pure and a no-op on already-valid transcripts.
515 Messages: repairToolUseResultPairs(a.Messages()),
516 Tools: a.Tools.Specs(),
517 Reasoning: a.Reasoning,
518 }
519 stream, err := a.Client.Stream(ctx, req)
520 if err != nil {
521 return provider.StopError, provider.Message{}, err
522 }
523
524 sink(EvAssistantStart{})
525
526 var (
527 stop provider.StopReason
528 finalErr error
529 finalMsg provider.Message
530 )
531
532 for ev := range stream {
533 switch e := ev.(type) {
534 case provider.EventStart:
535 // nothing
536 case provider.EventTextDelta:
537 sink(EvTextDelta{Delta: e.Delta})
538 case provider.EventToolStart:
539 sink(EvToolUseStart{ID: e.ID, Name: e.Name})
540 case provider.EventToolArgs:
541 sink(EvToolUseArgs{ID: e.ID, Delta: e.Delta})
542 case provider.EventToolEnd:
543 sink(EvToolUseEnd{ID: e.ID})
544 case provider.EventUsage:
545 cum := a.cost.Add(e.Usage)
546 sink(EvUsage{Usage: e.Usage, Cumulative: cum})
547 if a.OnUsage != nil {
548 a.OnUsage(cum)
549 }
550 case provider.EventDone:
551 stop = e.Stop
552 finalErr = e.Err
553 finalMsg = e.Message
554 }
555 }
556
557 // Append assistant message to transcript. Aborted turns (Esc / Ctrl+C)
558 // produce partial content. When the partial message is text only we
559 // keep whatever was streamed up to the cancel so the user does not
560 // lose visible work (a cut-off summary is still useful). If the
561 // partial message already contained tool-call blocks we drop the
562 // whole thing, because an unmatched tool_use would fail the next
563 // turn with a tool_result mismatch error.
564 keep := len(finalMsg.Content) > 0
565 if stop == provider.StopAborted && keep {
566 hasToolCall := false
567 for _, c := range finalMsg.Content {
568 if _, ok := c.(provider.ToolCallBlock); ok {
569 hasToolCall = true
570 break
571 }
572 }
573 if hasToolCall {
574 keep = false
575 }
576 }
577 if keep {
578 emit := finalMsg
579 suppress := false
580
581 // BeforeAssistantMessage hook: extensions can suppress or
582 // rewrite the visible text. The transcript keeps the
583 // model's original output so the model still sees what it
584 // said on subsequent turns.
585 if a.BeforeAssistantMessage != nil {
586 orig := extractText(finalMsg)
587 if orig != "" {
588 allowed, _, replacement := a.BeforeAssistantMessage(orig)
589 if !allowed {
590 suppress = true
591 } else if replacement != "" && replacement != orig {
592 emit = replaceText(finalMsg, replacement)
593 }
594 }
595 }
596
597 a.mu.Lock()
598 a.messages = append(a.messages, finalMsg)
599 a.rev++
600 a.mu.Unlock()
601 a.fireMessageAppended(finalMsg)
602 if !suppress {
603 sink(EvAssistantMessage{Message: emit})
604 }
605 // Now surface tool calls as EvToolCall events so UIs can render them
606 // in order before the tool results arrive.
607 for _, c := range finalMsg.Content {
608 if tc, ok := c.(provider.ToolCallBlock); ok {
609 sink(EvToolCall{ID: tc.ID, Name: tc.Name, Args: tc.Arguments})
610 }
611 }
612 }
613
614 return stop, finalMsg, finalErr
615}
616
617// executeTools runs every tool call in the assistant message and returns
618// a single tool-role message carrying all results.
619func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) {
620 var results []provider.Content
621 hadError := false
622
623 for _, c := range msg.Content {
624 tc, ok := c.(provider.ToolCallBlock)
625 if !ok {
626 continue
627 }
628 res := a.runOneTool(ctx, tc, sink)
629 if res.IsError {
630 hadError = true
631 }
632 results = append(results, provider.ToolResultBlock{
633 CallID: tc.ID,
634 Content: res.Content,
635 IsError: res.IsError,
636 })
637 sink(EvToolResult{ID: tc.ID, Result: res})
638 }
639
640 return provider.Message{
641 Role: provider.RoleTool,
642 Content: results,
643 Time: time.Now(),
644 }, hadError
645}
646
647func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult {
648 tool, err := a.Tools.Get(tc.Name)
649 if err != nil {
650 return ToolResult{
651 Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
652 IsError: true,
653 }
654 }
655
656 args := tc.Arguments
657
658 // Intercept hook: an extension or other guard can refuse the
659 // call before any side effect happens, OR rewrite the args
660 // seen by the tool. The model sees the reason as the tool
661 // error, learns from it, and (typically) proposes a different
662 // action; rewrites are invisible to the model (they apply only
663 // to the execution).
664 if a.BeforeToolExecute != nil {
665 allowed, reason, modified := a.BeforeToolExecute(tc)
666 if !allowed {
667 if reason == "" {
668 reason = "tool call refused by extension guard"
669 }
670 return ToolResult{
671 Content: []provider.Content{provider.TextBlock{Text: reason}},
672 IsError: true,
673 }
674 }
675 if len(modified) > 0 && json.Valid(modified) {
676 args = modified
677 }
678 }
679
680 if len(args) == 0 {
681 args = json.RawMessage("{}")
682 }
683
684 // Recover panics so a buggy tool does not crash the agent.
685 var res ToolResult
686 func() {
687 defer func() {
688 if r := recover(); r != nil {
689 res = ToolResult{
690 Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("panic: %v", r)}},
691 IsError: true,
692 }
693 }
694 }()
695 out, err := tool.Execute(ctx, args, func(text string) {
696 sink(EvToolProgress{ID: tc.ID, Text: text})
697 })
698 if err != nil {
699 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
700 res = ToolResult{
701 Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}},
702 IsError: true,
703 }
704 return
705 }
706 res = ToolResult{
707 Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
708 IsError: true,
709 }
710 return
711 }
712 res = out
713 }()
714 return res
715}
716
717// extractText concatenates all TextBlock content in a message. Used
718// by BeforeAssistantMessage so guards see a single string instead of
719// having to walk provider.Content themselves.
720func mirrorToolImagesAsUser(msg provider.Message) provider.Message {
721 var content []provider.Content
722 for _, c := range msg.Content {
723 tr, ok := c.(provider.ToolResultBlock)
724 if !ok {
725 continue
726 }
727 for _, inner := range tr.Content {
728 switch v := inner.(type) {
729 case provider.TextBlock:
730 // Keep short textual context so the model understands why
731 // the images appeared, but don't duplicate giant read
732 // outputs verbatim.
733 if len(v.Text) > 0 && len(v.Text) <= 500 {
734 content = append(content, v)
735 }
736 case provider.ImageBlock:
737 content = append(content, v)
738 }
739 }
740 }
741 if len(content) == 0 {
742 return provider.Message{}
743 }
744 prefix := provider.TextBlock{Text: "Tool output included the following image content:"}
745 content = append([]provider.Content{prefix}, content...)
746 return provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}
747}
748
749func extractText(msg provider.Message) string {
750 var out string
751 for _, c := range msg.Content {
752 if tb, ok := c.(provider.TextBlock); ok {
753 if out != "" {
754 out += "\n"
755 }
756 out += tb.Text
757 }
758 }
759 return out
760}
761
762// replaceText returns a copy of msg with every TextBlock replaced by
763// a single TextBlock containing replacement. Non-text content (tool
764// calls, etc.) is preserved in order.
765func replaceText(msg provider.Message, replacement string) provider.Message {
766 out := provider.Message{Role: msg.Role}
767 out.Content = make([]provider.Content, 0, len(msg.Content))
768 replaced := false
769 for _, c := range msg.Content {
770 if _, ok := c.(provider.TextBlock); ok {
771 if !replaced {
772 out.Content = append(out.Content, provider.TextBlock{Text: replacement})
773 replaced = true
774 }
775 continue
776 }
777 out.Content = append(out.Content, c)
778 }
779 if !replaced {
780 out.Content = append(out.Content, provider.TextBlock{Text: replacement})
781 }
782 return out
783}