v0.2.26 4c2e835

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

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