v0.2.99 4d92b3d

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

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