v0.1.20 e52a560

build.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"strings"
9
10	"github.com/patriceckhart/zot/internal/agent/tools"
11	"github.com/patriceckhart/zot/internal/core"
12	"github.com/patriceckhart/zot/internal/provider"
13	"github.com/patriceckhart/zot/internal/skills"
14)
15
16// Resolved is the effective configuration after merging CLI, config, defaults.
17type Resolved struct {
18	Provider   string
19	Model      string
20	Credential string // api key or oauth access token
21	AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
22	AccountID  string // ChatGPT account id (for openai oauth), "" otherwise
23	BaseURL    string
24	CWD        string
25	Reasoning  string
26
27	ToolRegistry core.Registry
28	ToolSummary  []ToolSummary
29	SystemPrompt string
30	MaxSteps     int
31	Sandbox      *tools.Sandbox
32
33	// SkillTool is the on-demand skill loader registered with the
34	// agent's tool registry, or nil if no SKILL.md files were
35	// discovered. Exposed so the tui can list / preview skills.
36	SkillTool *skills.Tool
37
38	// Bookkeeping for MergeExtensionTools. Captured at Resolve time
39	// so the system prompt can be rebuilt later without re-running
40	// resolve.
41	systemAppend     []string
42	systemCustom     string
43	toolDescriptions map[string]string
44}
45
46// HasCredential reports whether a credential was resolved.
47func (r Resolved) HasCredential() bool { return r.Credential != "" }
48
49// MergeExtensionTools folds every tool registered by an extension
50// into r's ToolRegistry and re-renders the system prompt's tool
51// summary so the model sees both built-in and extension tools.
52//
53// Idempotent: calling twice with the same manager state has no
54// effect on the second pass (existing names are preserved). Built-in
55// tools always win on conflict.
56func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
57	if mgr == nil {
58		return
59	}
60	infos := mgr.Tools()
61	if len(infos) == 0 {
62		return
63	}
64	changed := false
65	for _, info := range infos {
66		if _, exists := r.ToolRegistry[info.Name]; exists {
67			continue
68		}
69		r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
70		changed = true
71	}
72	if !changed {
73		return
74	}
75	// Re-render the system prompt with the merged tool list. Skill
76	// addendum is preserved by walking the existing append slice.
77	append_ := r.systemAppend
78	r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
79		CWD:    r.CWD,
80		Tools:  toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
81		Custom: r.systemCustom,
82		Append: append_,
83	})
84}
85
86// ExtensionToolSource is the slice of the extension manager that
87// MergeExtensionTools needs. Lives here as an interface so the
88// build package doesn't import internal/agent/extensions (which
89// imports core, which imports... avoid the cycle).
90type ExtensionToolSource interface {
91	Tools() []ExtensionToolInfo
92	NewExtensionTool(info ExtensionToolInfo) core.Tool
93}
94
95// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
96// ExtensionToolSource here without importing the extensions
97// package. The cli wires a tiny adapter to bridge them.
98type ExtensionToolInfo struct {
99	Extension   string
100	Name        string
101	Description string
102	Schema      []byte
103}
104
105// toolSummariesFromRegistry rebuilds the system-prompt tool list
106// from a (possibly extended) registry, using cached descriptions for
107// the human-readable summary text.
108func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
109	out := make([]ToolSummary, 0, len(reg))
110	for name, t := range reg {
111		desc := t.Description()
112		if d, ok := cached[name]; ok && d != "" {
113			desc = d
114		}
115		out = append(out, ToolSummary{Name: name, Description: desc})
116	}
117	return out
118}
119
120// Resolve merges args, config, and env into a Resolved set.
121//
122// Unlike the earlier version, Resolve NEVER returns an error for
123// missing credentials: the TUI can start without them and launch a
124// login flow. requireCred controls whether missing credentials are a
125// hard error (used by print/json modes).
126func Resolve(args Args, requireCred bool) (Resolved, error) {
127	cfg, _ := LoadConfig()
128
129	// User-requested provider (explicit > config > default).
130	provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
131	if provName != "anthropic" && provName != "openai" && provName != "ollama" {
132		// Unknown provider (maybe removed or renamed). Fall back to
133		// the first provider that has credentials, or anthropic.
134		provName = "anthropic"
135		if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
136			provName = "openai"
137		}
138		if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
139			provName = "anthropic"
140		}
141		// Reset the saved config so this doesn't keep happening.
142		cfg.Provider = provName
143		cfg.Model = ""
144		_ = SaveConfig(cfg)
145	}
146
147	var (
148		cred      string
149		method    string
150		accountID string
151		credErr   error
152	)
153	if provName == "ollama" {
154		if args.BaseURL == "" {
155			args.BaseURL = "http://localhost:11434"
156		}
157		cred = firstNonEmpty(args.APIKey, "ollama")
158		method = "apikey"
159	} else {
160		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
161	}
162
163	// If the user did NOT explicitly pick a provider and the default one
164	// has no credentials, auto-fall-back to whichever provider is actually
165	// logged in. That way running plain `zot` after `/login` (any provider)
166	// never shows a "not logged in" banner.
167	userPickedProvider := args.Provider != ""
168	if credErr != nil && !userPickedProvider && provName != "ollama" {
169		other := "openai"
170		if provName == "openai" {
171			other = "anthropic"
172		}
173		if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
174			provName = other
175			cred, method, accountID, credErr = c, m, a, err
176		}
177	}
178
179	model := firstNonEmpty(args.Model, cfg.Model)
180	if model == "" {
181		switch provName {
182		case "openai":
183			model = "gpt-5"
184		case "ollama":
185			return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
186		default:
187			model = provider.DefaultModel.ID
188		}
189	}
190	// If the resolved model belongs to a different provider (e.g. config
191	// says gpt-5 but we fell back to anthropic), pick that provider's default.
192	if provName != "ollama" {
193		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
194			if provName == "openai" {
195				model = "gpt-5"
196			} else {
197				model = provider.DefaultModel.ID
198			}
199		}
200	}
201	resolvedModel, err := provider.FindModel(provName, model)
202	if err != nil && provName != "ollama" {
203		return Resolved{}, err
204	}
205	if err != nil && provName == "ollama" {
206		resolvedModel = provider.Model{
207			Provider:      "ollama",
208			ID:            model,
209			DisplayName:   model,
210			ContextWindow: 32768,
211			MaxOutput:     8192,
212			BaseURL:       args.BaseURL,
213			Source:        "ollama",
214		}
215	}
216
217	// If the model defines a base URL (e.g. local ollama) and the
218	// user didn't pass --base-url, use the model's URL.
219	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
220		args.BaseURL = resolvedModel.BaseURL
221	}
222
223	// If the model has a base URL, credentials are optional (local
224	// models like ollama don't need real API keys).
225	if resolvedModel.BaseURL != "" && credErr != nil {
226		cred = "ollama"
227		credErr = nil
228		requireCred = false
229	}
230
231	if credErr != nil && requireCred {
232		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
233			credErr, envVarName(provName))
234	}
235
236	sandbox := tools.NewSandbox(args.CWD)
237	reg := buildToolRegistry(args, args.CWD, sandbox)
238
239	// Skill discovery: scan project + global locations + built-in
240	// skills shipped with the binary. If any are found, register
241	// the on-demand `skill` loader tool plus a system-prompt
242	// manifest so the model knows what's available.
243	//
244	// --no-skill bypasses the entire mechanism: no manifest in the
245	// system prompt, no `skill` tool in the registry. Useful for a
246	// clean-room run with zero extra context biasing the model.
247	var (
248		discovered    []*skills.Skill
249		skillTool     *skills.Tool
250		skillAddendum string
251	)
252	if !args.NoSkill {
253		homeDir, _ := os.UserHomeDir()
254		discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
255		if len(discovered) > 0 {
256			skillTool = skills.NewTool(discovered)
257			reg[skillTool.Name()] = skillTool
258			skillAddendum = skills.SystemPromptAddendum(discovered)
259		}
260	}
261	_ = skillTool
262
263	summaries := toolSummaries(reg, args)
264
265	append_ := append([]string(nil), args.AppendSystemPrompt...)
266	if skillAddendum != "" {
267		append_ = append(append_, skillAddendum)
268	}
269
270	// Custom system prompt resolution order:
271	//   1. --system-prompt flag (highest priority; ad-hoc per run)
272	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
273	//   3. built-in default (defaultIdentity + defaultGuidelines)
274	custom := args.SystemPrompt
275	if custom == "" {
276		custom = readUserSystemPrompt(ZotHome())
277	}
278
279	sys := BuildSystemPrompt(SystemPromptOpts{
280		CWD:    args.CWD,
281		Tools:  summaries,
282		Custom: custom,
283		Append: append_,
284	})
285
286	reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
287
288	max := args.MaxSteps
289	if max <= 0 {
290		max = 50
291	}
292
293	return Resolved{
294		Provider:         provName,
295		Model:            model,
296		Credential:       cred,
297		AuthMethod:       method,
298		AccountID:        accountID,
299		BaseURL:          args.BaseURL,
300		CWD:              args.CWD,
301		Reasoning:        reasoning,
302		ToolRegistry:     reg,
303		ToolSummary:      summaries,
304		SystemPrompt:     sys,
305		MaxSteps:         max,
306		Sandbox:          sandbox,
307		SkillTool:        skillTool,
308		systemAppend:     append_,
309		systemCustom:     custom,
310		toolDescriptions: descMapFromSummaries(summaries),
311	}, nil
312}
313
314// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
315// trimmed contents, or "" when the file is missing / unreadable /
316// empty. Errors are intentionally swallowed: the file is optional,
317// and any failure to read it should fall back to the built-in
318// default system prompt rather than crash the run.
319func readUserSystemPrompt(zotHome string) string {
320	if zotHome == "" {
321		return ""
322	}
323	path := filepath.Join(zotHome, "SYSTEM.md")
324	raw, err := os.ReadFile(path)
325	if err != nil {
326		return ""
327	}
328	return strings.TrimSpace(string(raw))
329}
330
331// descMapFromSummaries indexes the human-readable descriptions for
332// the renderToolsSection rebuild path.
333func descMapFromSummaries(summaries []ToolSummary) map[string]string {
334	out := make(map[string]string, len(summaries))
335	for _, s := range summaries {
336		out[s.Name] = s.Description
337	}
338	return out
339}
340
341// NewClient returns a provider.Client for r, choosing the auth mode
342// based on r.AuthMethod. Panics if no credential is present; callers
343// must check HasCredential() first.
344func (r Resolved) NewClient() provider.Client {
345	if !r.HasCredential() {
346		panic("NewClient called without credential; check HasCredential first")
347	}
348	switch r.Provider {
349	case "ollama":
350		return provider.NewOpenAI(r.Credential, r.BaseURL)
351	case "openai":
352		if r.AuthMethod == "oauth" {
353			inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
354			return r.wrapWithRefresh(inner)
355		}
356		return provider.NewOpenAI(r.Credential, r.BaseURL)
357	default:
358		if r.AuthMethod == "oauth" {
359			inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
360			return r.wrapWithRefresh(inner)
361		}
362		return provider.NewAnthropic(r.Credential, r.BaseURL)
363	}
364}
365
366// wrapWithRefresh wraps an OAuth client so the access token is
367// refreshed automatically before each API call. Without this, long
368// sessions (hours) silently fail when the 1-hour token expires.
369func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
370	provName := r.Provider
371	baseURL := r.BaseURL
372	accountID := r.AccountID
373
374	refreshFn := func(ctx context.Context) (string, error) {
375		tok, err := refreshIfExpired(provName, loadOAuthToken(provName))
376		if err != nil {
377			return "", err
378		}
379		return tok.AccessToken, nil
380	}
381
382	factory := func(token string) provider.Client {
383		switch provName {
384		case "openai":
385			return provider.NewOpenAICodex(token, accountID, baseURL)
386		default:
387			return provider.NewAnthropicOAuth(token, baseURL)
388		}
389	}
390
391	return provider.NewRefreshingClient(inner, refreshFn, factory)
392}
393
394// UseSandbox replaces the sandbox pointer that every tool in r's
395// registry references. Used to keep the /jail state stable across
396// agent rebuilds (e.g. /login, /model switching providers).
397func (r *Resolved) UseSandbox(s *tools.Sandbox) {
398	if s == nil || r == nil {
399		return
400	}
401	r.Sandbox = s
402	for name, t := range r.ToolRegistry {
403		switch v := t.(type) {
404		case *tools.ReadTool:
405			v.Sandbox = s
406		case *tools.WriteTool:
407			v.Sandbox = s
408		case *tools.EditTool:
409			v.Sandbox = s
410		case *tools.BashTool:
411			v.Sandbox = s
412		}
413		_ = name
414	}
415}
416
417// NewAgent constructs a core.Agent from r. Requires a credential.
418func (r Resolved) NewAgent() *core.Agent {
419	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
420	a.MaxSteps = r.MaxSteps
421	a.Reasoning = r.Reasoning
422	return a
423}
424
425func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
426	if args.NoTools {
427		return core.Registry{}
428	}
429	all := map[string]core.Tool{
430		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
431		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
432		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
433		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
434	}
435	reg := core.Registry{}
436	if len(args.Tools) == 0 {
437		for _, t := range all {
438			reg[t.Name()] = t
439		}
440		return reg
441	}
442	for _, name := range args.Tools {
443		if t, ok := all[name]; ok {
444			reg[name] = t
445		}
446	}
447	return reg
448}
449
450func toolSummaries(reg core.Registry, args Args) []ToolSummary {
451	order := []string{"read", "write", "edit", "bash"}
452	var out []ToolSummary
453	for _, name := range order {
454		if t, ok := reg[name]; ok {
455			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
456		}
457	}
458	return out
459}
460
461func firstNonEmpty(vals ...string) string {
462	for _, v := range vals {
463		if v != "" {
464			return v
465		}
466	}
467	return ""
468}
469
470func envVarName(provider string) string {
471	switch provider {
472	case "openai":
473		return "OPENAI"
474	case "ollama":
475		return "OLLAMA"
476	default:
477		return "ANTHROPIC"
478	}
479}

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