v0.3.38 1e4a8c4

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// AppendUserContext adds a user-role message to the transcript without
242// starting a model turn. Hosts use it for context gathered outside the agent
243// loop, such as the output of an explicitly invoked shell command.
244func (a *Agent) AppendUserContext(text string, meta map[string]string) {
245	msg := provider.Message{
246		Role:    provider.RoleUser,
247		Content: []provider.Content{provider.TextBlock{Text: text}},
248		Time:    time.Now(),
249		Meta:    meta,
250	}
251	a.mu.Lock()
252	a.messages = append(a.messages, msg)
253	a.rev++
254	a.mu.Unlock()
255	a.fireMessageAppended(msg)
256}
257
258// Cost returns the cumulative usage.
259func (a *Agent) Cost() provider.Usage {
260	a.mu.Lock()
261	defer a.mu.Unlock()
262	return a.cost.Total
263}
264
265// SeedCost sets the cumulative usage as a baseline before the first
266// turn runs. Used when transferring state from another agent (model
267// or provider switch) so the running cost meter doesn't reset to 0.
268func (a *Agent) SeedCost(u provider.Usage) {
269	a.mu.Lock()
270	defer a.mu.Unlock()
271	a.cost.Seed(u)
272}
273
274// LastTurnUsage returns the per-turn usage of the most recent
275// completed turn. Drives the "context used" gauge in the status bar
276// without waiting for the next turn to land.
277func (a *Agent) LastTurnUsage() provider.Usage {
278	a.mu.Lock()
279	defer a.mu.Unlock()
280	return a.cost.LastTurn
281}
282
283// SeedLastTurnUsage primes the per-turn snapshot. Used on resume so
284// the gauge reflects the prompt size of the last turn in the session
285// file instead of starting at zero.
286func (a *Agent) SeedLastTurnUsage(u provider.Usage) {
287	a.mu.Lock()
288	defer a.mu.Unlock()
289	a.cost.LastTurn = u
290}
291
292// fireMessageAppended invokes OnMessageAppended without holding the
293// agent mutex, so the host's persistence callback can take its own
294// locks without deadlocking the agent loop. Tolerates a nil hook so
295// non-persisting callers (tests, RPC mode) don't have to set it.
296func (a *Agent) fireMessageAppended(m provider.Message) {
297	if a.OnMessageAppended != nil {
298		a.OnMessageAppended(m)
299	}
300}
301
302// Prompt sends a user message and runs the agent loop until the model
303// stops or an error occurs. Events are delivered via sink in order.
304// sink must not block the caller for long; buffer as needed.
305func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error {
306	if sink == nil {
307		sink = func(AgentEvent) {}
308	}
309	sink = a.wrapSink(sink)
310	content := []provider.Content{}
311	if text != "" {
312		content = append(content, provider.TextBlock{Text: text})
313	}
314	for _, img := range images {
315		content = append(content, img)
316	}
317	user := provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}
318
319	a.mu.Lock()
320	a.messages = append(a.messages, user)
321	a.rev++
322	a.mu.Unlock()
323	a.fireMessageAppended(user)
324	sink(EvUserMessage{Message: user})
325
326	return a.runLoop(ctx, sink)
327}
328
329// Continue runs the agent loop against the existing transcript. Used
330// after appending tool results manually or to retry.
331func (a *Agent) Continue(ctx context.Context, sink func(AgentEvent)) error {
332	if sink == nil {
333		sink = func(AgentEvent) {}
334	}
335	sink = a.wrapSink(sink)
336	return a.runLoop(ctx, sink)
337}
338
339// wrapSink composes the per-call sink with a.OnEvent (if set) so the
340// extension manager (or any other observer) sees every AgentEvent
341// without having to thread itself through every Prompt callsite.
342func (a *Agent) wrapSink(sink func(AgentEvent)) func(AgentEvent) {
343	if a.OnEvent == nil {
344		return sink
345	}
346	obs := a.OnEvent
347	return func(ev AgentEvent) {
348		obs(ev)
349		sink(ev)
350	}
351}
352
353func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
354	for step := 1; a.MaxSteps <= 0 || step <= a.MaxSteps; step++ {
355		// Messages queued while the agent was busy are delivered
356		// before the next model call. This is the safe boundary:
357		// any previous tool batch has already completed and its
358		// results have been appended, but no new provider request has
359		// started yet.
360		if pending := a.drainQueuedMessages(); len(pending) > 0 {
361			a.appendQueuedAsUser(pending, sink)
362		}
363
364		sink(EvTurnStart{Step: step})
365		if a.BeforeTurn != nil {
366			if allowed, reason := a.BeforeTurn(step); !allowed {
367				if reason == "" {
368					reason = "turn blocked by extension guard"
369				}
370				sink(EvTurnEnd{Stop: provider.StopError, Err: fmt.Errorf("%s", reason)})
371				sink(EvDone{})
372				return nil
373			}
374		}
375
376		var (
377			stop         provider.StopReason
378			assistantMsg provider.Message
379			err          error
380		)
381		for attempt := 0; ; attempt++ {
382			stop, assistantMsg, err = a.oneTurn(ctx, sink)
383			sink(EvTurnEnd{Stop: stop, Err: err})
384			if err == nil || !a.canRetryError(err, attempt) {
385				break
386			}
387			a.dropLastAssistantMessage()
388			if sleepErr := sleepRetry(ctx, a.retryDelay(attempt)); sleepErr != nil {
389				return sleepErr
390			}
391		}
392		if err != nil {
393			return err
394		}
395
396		if stop == provider.StopToolUse {
397			// Execute each tool call, append a single tool-results message, continue.
398			toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink)
399			a.mu.Lock()
400			a.messages = append(a.messages, toolMsg)
401			a.rev++
402			// OpenAI's chat-completions tool message shape is text-centric.
403			// Vision models reliably consume images when they arrive as user
404			// content, so when a tool result contains images we mirror them
405			// into a synthetic user message immediately after the tool result.
406			// This keeps the transcript self-contained for providers that can
407			// see image blocks in tool messages while making OpenAI vision
408			// models actually receive the image bytes.
409			//
410			// The OpenAI Responses route ("openai-codex") has the same
411			// text-centric tool-output shape: a function_call_output only
412			// carries a string, so images in a tool result never reach the
413			// model. Both providers serialize images correctly when they
414			// arrive as user content, so the mirror covers them both.
415			var imageMirror provider.Message
416			if a.Client != nil && (a.Client.Name() == "openai" || a.Client.Name() == "openai-codex") {
417				if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 {
418					a.messages = append(a.messages, mirror)
419					a.rev++
420					imageMirror = mirror
421				}
422			}
423			a.mu.Unlock()
424			a.fireMessageAppended(toolMsg)
425			if len(imageMirror.Content) > 0 {
426				a.fireMessageAppended(imageMirror)
427			}
428			// If context was cancelled during tool execution, bail out.
429			if err := ctx.Err(); err != nil {
430				sink(EvDone{})
431				return err
432			}
433			_ = hadError
434			continue
435		}
436
437		// If the assistant stopped without tool calls but a message was
438		// queued while it was speaking, loop once more so that message
439		// is appended and answered instead of waiting until a later
440		// top-level prompt.
441		if ctx.Err() == nil && a.QueuedMessageCount() > 0 {
442			continue
443		}
444
445		// Terminal stop (end, length, error, aborted).
446		sink(EvDone{})
447		return nil
448	}
449	if a.MaxSteps > 0 {
450		sink(EvDone{})
451		return fmt.Errorf("max steps (%d) exceeded", a.MaxSteps)
452	}
453	return nil
454}
455
456func (a *Agent) canRetryError(err error, attempt int) bool {
457	if err == nil || a.MaxRetries <= 0 || attempt >= a.MaxRetries {
458		return false
459	}
460	if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
461		return false
462	}
463	msg := strings.ToLower(err.Error())
464	if msg == "" || isNonRetryableProviderLimit(msg) {
465		return false
466	}
467	needles := []string{
468		"overloaded", "provider returned error", "rate limit", "ratelimit", "too many requests",
469		"429", "http 429", "500", "http 500", "502", "http 502", "503", "http 503", "504", "http 504",
470		"service unavailable", "server error", "internal error", "network error", "connection error",
471		"connection refused", "connection lost", "fetch failed", "upstream connect", "reset before headers",
472		"socket hang up", "ended without", "stream ended before", "did not get a response", "timed out",
473		"timeout", "terminated", "unexpected eof", "transport failure",
474		// OpenAI's ChatGPT/Codex backend returns this generic message (with a
475		// request ID) for transient server failures and explicitly says
476		// "You can retry your request".
477		"an error occurred while processing your request",
478		// Explicit retry guidance emitted by provider backends (OpenAI
479		// Responses, AWS Bedrock stream exceptions) with varying prefixes.
480		"you can retry your request", "try your request again", "please retry your request",
481		// Capacity messages from the ChatGPT/Codex backend, e.g.
482		// "Our servers are currently overloaded. Please try again later."
483		// The trailing advice also shows up on its own for transient
484		// capacity failures; usage/quota limits are filtered out above by
485		// isNonRetryableProviderLimit before this list is consulted.
486		"servers are currently overloaded", "servers are busy", "try again later",
487	}
488	for _, needle := range needles {
489		if strings.Contains(msg, needle) {
490			return true
491		}
492	}
493	return false
494}
495
496func isNonRetryableProviderLimit(msg string) bool {
497	needles := []string{
498		"usage limit", "monthly usage limit", "freeusagelimit", "gousagelimit",
499		"available balance", "insufficient_quota", "out of budget", "quota exceeded", "billing",
500	}
501	for _, needle := range needles {
502		if strings.Contains(msg, needle) {
503			return true
504		}
505	}
506	return false
507}
508
509func (a *Agent) retryDelay(attempt int) time.Duration {
510	base := a.RetryBaseDelay
511	if base <= 0 {
512		base = 2 * time.Second
513	}
514	return base * time.Duration(1<<attempt)
515}
516
517func sleepRetry(ctx context.Context, d time.Duration) error {
518	if d <= 0 {
519		return ctx.Err()
520	}
521	t := time.NewTimer(d)
522	defer t.Stop()
523	select {
524	case <-ctx.Done():
525		return ctx.Err()
526	case <-t.C:
527		return nil
528	}
529}
530
531func (a *Agent) dropLastAssistantMessage() {
532	a.mu.Lock()
533	defer a.mu.Unlock()
534	if n := len(a.messages); n > 0 && a.messages[n-1].Role == provider.RoleAssistant {
535		a.messages = a.messages[:n-1]
536		a.rev++
537	}
538}
539
540// oneTurn calls the LLM once, forwards events, returns the stop reason
541// and the assembled assistant message (already appended to the transcript).
542func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) {
543	req := provider.Request{
544		Model:  a.Model,
545		System: a.System,
546		// Repair any dangling tool_use blocks before sending. A turn
547		// aborted mid-flight (cancel, connection drop, ECONNREFUSED to a
548		// dev server, etc.) can leave an assistant tool_use with no
549		// matching tool_result in the live transcript. The load-time
550		// repair in OpenSession only runs on restart, so without this the
551		// next in-process request is rejected by providers like Anthropic
552		// with "tool_use ids were found without tool_result blocks". The
553		// repair is pure and a no-op on already-valid transcripts.
554		Messages:    repairToolUseResultPairs(a.Messages()),
555		Tools:       a.Tools.Specs(),
556		Reasoning:   a.Reasoning,
557		MaxTokens:   a.MaxTokens,
558		Temperature: a.Temperature,
559	}
560	stream, err := a.Client.Stream(ctx, req)
561	if err != nil {
562		return provider.StopError, provider.Message{}, err
563	}
564
565	sink(EvAssistantStart{})
566
567	var (
568		stop     provider.StopReason
569		finalErr error
570		finalMsg provider.Message
571	)
572
573	for ev := range stream {
574		switch e := ev.(type) {
575		case provider.EventStart:
576			// nothing
577		case provider.EventTextDelta:
578			sink(EvTextDelta{Delta: e.Delta})
579		case provider.EventToolStart:
580			sink(EvToolUseStart{ID: e.ID, Name: e.Name})
581		case provider.EventToolArgs:
582			sink(EvToolUseArgs{ID: e.ID, Delta: e.Delta})
583		case provider.EventToolEnd:
584			sink(EvToolUseEnd{ID: e.ID})
585		case provider.EventUsage:
586			cum := a.cost.Add(e.Usage)
587			sink(EvUsage{Usage: e.Usage, Cumulative: cum})
588			if a.OnUsage != nil {
589				a.OnUsage(cum)
590			}
591		case provider.EventDone:
592			stop = e.Stop
593			finalErr = e.Err
594			finalMsg = e.Message
595		}
596	}
597
598	// Append assistant message to transcript. Aborted turns (Esc / Ctrl+C)
599	// produce partial content. When the partial message is text only we
600	// keep whatever was streamed up to the cancel so the user does not
601	// lose visible work (a cut-off summary is still useful). If the
602	// partial message already contained tool-call blocks we drop the
603	// whole thing, because an unmatched tool_use would fail the next
604	// turn with a tool_result mismatch error.
605	keep := len(finalMsg.Content) > 0
606	if stop == provider.StopAborted && keep {
607		hasToolCall := false
608		for _, c := range finalMsg.Content {
609			if _, ok := c.(provider.ToolCallBlock); ok {
610				hasToolCall = true
611				break
612			}
613		}
614		if hasToolCall {
615			keep = false
616		}
617	}
618	if keep {
619		emit := finalMsg
620		suppress := false
621
622		// BeforeAssistantMessage hook: extensions can suppress or
623		// rewrite the visible text. The transcript keeps the
624		// model's original output so the model still sees what it
625		// said on subsequent turns.
626		if a.BeforeAssistantMessage != nil {
627			orig := extractText(finalMsg)
628			if orig != "" {
629				allowed, _, replacement := a.BeforeAssistantMessage(orig)
630				if !allowed {
631					suppress = true
632				} else if replacement != "" && replacement != orig {
633					emit = replaceText(finalMsg, replacement)
634				}
635			}
636		}
637
638		a.mu.Lock()
639		a.messages = append(a.messages, finalMsg)
640		a.rev++
641		a.mu.Unlock()
642		a.fireMessageAppended(finalMsg)
643		if !suppress {
644			sink(EvAssistantMessage{Message: emit})
645		}
646		// Now surface tool calls as EvToolCall events so UIs can render them
647		// in order before the tool results arrive.
648		for _, c := range finalMsg.Content {
649			if tc, ok := c.(provider.ToolCallBlock); ok {
650				sink(EvToolCall{ID: tc.ID, Name: tc.Name, Args: tc.Arguments})
651			}
652		}
653	}
654
655	return stop, finalMsg, finalErr
656}
657
658// executeTools runs every tool call in the assistant message and returns
659// a single tool-role message carrying all results.
660func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) {
661	var results []provider.Content
662	var addedTools []string
663	hadError := false
664
665	for _, c := range msg.Content {
666		tc, ok := c.(provider.ToolCallBlock)
667		if !ok {
668			continue
669		}
670		res := a.runOneTool(ctx, tc, sink)
671		if res.IsError {
672			hadError = true
673		}
674		results = append(results, provider.ToolResultBlock{
675			CallID:  tc.ID,
676			Content: res.Content,
677			IsError: res.IsError,
678		})
679		for _, name := range res.ActivateTools {
680			if _, err := a.Tools.Get(name); err == nil && !containsString(addedTools, name) {
681				addedTools = append(addedTools, name)
682			}
683		}
684		sink(EvToolResult{ID: tc.ID, Result: res})
685	}
686
687	return provider.Message{
688		Role:           provider.RoleTool,
689		Content:        results,
690		Time:           time.Now(),
691		AddedToolNames: addedTools,
692	}, hadError
693}
694
695func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult {
696	tool, err := a.Tools.Get(tc.Name)
697	if err != nil {
698		return ToolResult{
699			Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
700			IsError: true,
701		}
702	}
703
704	args := tc.Arguments
705
706	// Intercept hook: an extension or other guard can refuse the
707	// call before any side effect happens, OR rewrite the args
708	// seen by the tool. The model sees the reason as the tool
709	// error, learns from it, and (typically) proposes a different
710	// action; rewrites are invisible to the model (they apply only
711	// to the execution).
712	if a.BeforeToolExecute != nil {
713		allowed, reason, modified := a.BeforeToolExecute(tc)
714		if !allowed {
715			if reason == "" {
716				reason = "tool call refused by extension guard"
717			}
718			return ToolResult{
719				Content: []provider.Content{provider.TextBlock{Text: reason}},
720				IsError: true,
721			}
722		}
723		if len(modified) > 0 && json.Valid(modified) {
724			args = modified
725		}
726	}
727
728	if len(args) == 0 {
729		args = json.RawMessage("{}")
730	}
731
732	// Recover panics so a buggy tool does not crash the agent.
733	var res ToolResult
734	func() {
735		defer func() {
736			if r := recover(); r != nil {
737				res = ToolResult{
738					Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("panic: %v", r)}},
739					IsError: true,
740				}
741			}
742		}()
743		out, err := tool.Execute(ctx, args, func(text string) {
744			sink(EvToolProgress{ID: tc.ID, Text: text})
745		})
746		if err != nil {
747			if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
748				res = ToolResult{
749					Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}},
750					IsError: true,
751				}
752				return
753			}
754			res = ToolResult{
755				Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
756				IsError: true,
757			}
758			return
759		}
760		res = out
761	}()
762	return res
763}
764
765// extractText concatenates all TextBlock content in a message. Used
766// by BeforeAssistantMessage so guards see a single string instead of
767// having to walk provider.Content themselves.
768func mirrorToolImagesAsUser(msg provider.Message) provider.Message {
769	var content []provider.Content
770	for _, c := range msg.Content {
771		tr, ok := c.(provider.ToolResultBlock)
772		if !ok {
773			continue
774		}
775		for _, inner := range tr.Content {
776			switch v := inner.(type) {
777			case provider.TextBlock:
778				// Keep short textual context so the model understands why
779				// the images appeared, but don't duplicate giant read
780				// outputs verbatim.
781				if len(v.Text) > 0 && len(v.Text) <= 500 {
782					content = append(content, v)
783				}
784			case provider.ImageBlock:
785				content = append(content, v)
786			}
787		}
788	}
789	if len(content) == 0 {
790		return provider.Message{}
791	}
792	prefix := provider.TextBlock{Text: "Tool output included the following image content:"}
793	content = append([]provider.Content{prefix}, content...)
794	return provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()}
795}
796
797func containsString(values []string, want string) bool {
798	for _, value := range values {
799		if value == want {
800			return true
801		}
802	}
803	return false
804}
805
806func extractText(msg provider.Message) string {
807	var out string
808	for _, c := range msg.Content {
809		if tb, ok := c.(provider.TextBlock); ok {
810			if out != "" {
811				out += "\n"
812			}
813			out += tb.Text
814		}
815	}
816	return out
817}
818
819// replaceText returns a copy of msg with every TextBlock replaced by
820// a single TextBlock containing replacement. Non-text content (tool
821// calls, etc.) is preserved in order.
822func replaceText(msg provider.Message, replacement string) provider.Message {
823	out := provider.Message{Role: msg.Role}
824	out.Content = make([]provider.Content, 0, len(msg.Content))
825	replaced := false
826	for _, c := range msg.Content {
827		if _, ok := c.(provider.TextBlock); ok {
828			if !replaced {
829				out.Content = append(out.Content, provider.TextBlock{Text: replacement})
830				replaced = true
831			}
832			continue
833		}
834		out.Content = append(out.Content, c)
835	}
836	if !replaced {
837		out.Content = append(out.Content, provider.TextBlock{Text: replacement})
838	}
839	return out
840}

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