v0.3.47 56b82c0

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

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