v0.2.11 498b769

agent.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close