v0.2.22 3d031dd

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		// 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}

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