v0.0.24 83ae236

build.go RawHistoryFolder

1package agent
2
3import (
4	"fmt"
5	"os"
6
7	"github.com/patriceckhart/zot/internal/agent/tools"
8	"github.com/patriceckhart/zot/internal/core"
9	"github.com/patriceckhart/zot/internal/provider"
10	"github.com/patriceckhart/zot/internal/skills"
11)
12
13// Resolved is the effective configuration after merging CLI, config, defaults.
14type Resolved struct {
15	Provider   string
16	Model      string
17	Credential string // api key or oauth access token
18	AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
19	AccountID  string // ChatGPT account id (for openai oauth), "" otherwise
20	BaseURL    string
21	CWD        string
22	Reasoning  string
23
24	ToolRegistry core.Registry
25	ToolSummary  []ToolSummary
26	SystemPrompt string
27	MaxSteps     int
28	Sandbox      *tools.Sandbox
29
30	// SkillTool is the on-demand skill loader registered with the
31	// agent's tool registry, or nil if no SKILL.md files were
32	// discovered. Exposed so the tui can list / preview skills.
33	SkillTool *skills.Tool
34
35	// Bookkeeping for MergeExtensionTools. Captured at Resolve time
36	// so the system prompt can be rebuilt later without re-running
37	// resolve.
38	systemAppend     []string
39	systemCustom     string
40	toolDescriptions map[string]string
41}
42
43// HasCredential reports whether a credential was resolved.
44func (r Resolved) HasCredential() bool { return r.Credential != "" }
45
46// MergeExtensionTools folds every tool registered by an extension
47// into r's ToolRegistry and re-renders the system prompt's tool
48// summary so the model sees both built-in and extension tools.
49//
50// Idempotent: calling twice with the same manager state has no
51// effect on the second pass (existing names are preserved). Built-in
52// tools always win on conflict.
53func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
54	if mgr == nil {
55		return
56	}
57	infos := mgr.Tools()
58	if len(infos) == 0 {
59		return
60	}
61	changed := false
62	for _, info := range infos {
63		if _, exists := r.ToolRegistry[info.Name]; exists {
64			continue
65		}
66		r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
67		changed = true
68	}
69	if !changed {
70		return
71	}
72	// Re-render the system prompt with the merged tool list. Skill
73	// addendum is preserved by walking the existing append slice.
74	append_ := r.systemAppend
75	r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
76		CWD:    r.CWD,
77		Tools:  toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
78		Custom: r.systemCustom,
79		Append: append_,
80	})
81}
82
83// ExtensionToolSource is the slice of the extension manager that
84// MergeExtensionTools needs. Lives here as an interface so the
85// build package doesn't import internal/agent/extensions (which
86// imports core, which imports... avoid the cycle).
87type ExtensionToolSource interface {
88	Tools() []ExtensionToolInfo
89	NewExtensionTool(info ExtensionToolInfo) core.Tool
90}
91
92// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
93// ExtensionToolSource here without importing the extensions
94// package. The cli wires a tiny adapter to bridge them.
95type ExtensionToolInfo struct {
96	Extension   string
97	Name        string
98	Description string
99	Schema      []byte
100}
101
102// toolSummariesFromRegistry rebuilds the system-prompt tool list
103// from a (possibly extended) registry, using cached descriptions for
104// the human-readable summary text.
105func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
106	out := make([]ToolSummary, 0, len(reg))
107	for name, t := range reg {
108		desc := t.Description()
109		if d, ok := cached[name]; ok && d != "" {
110			desc = d
111		}
112		out = append(out, ToolSummary{Name: name, Description: desc})
113	}
114	return out
115}
116
117// Resolve merges args, config, and env into a Resolved set.
118//
119// Unlike the earlier version, Resolve NEVER returns an error for
120// missing credentials: the TUI can start without them and launch a
121// login flow. requireCred controls whether missing credentials are a
122// hard error (used by print/json modes).
123func Resolve(args Args, requireCred bool) (Resolved, error) {
124	cfg, _ := LoadConfig()
125
126	// User-requested provider (explicit > config > default).
127	provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
128	if provName != "anthropic" && provName != "openai" {
129		return Resolved{}, fmt.Errorf("provider must be anthropic or openai (got %q)", provName)
130	}
131
132	// Try the requested provider first.
133	cred, method, accountID, credErr := ResolveCredentialFull(provName, args.APIKey)
134
135	// If the user did NOT explicitly pick a provider and the default one
136	// has no credentials, auto-fall-back to whichever provider is actually
137	// logged in. That way running plain `zot` after `/login` (any provider)
138	// never shows a "not logged in" banner.
139	userPickedProvider := args.Provider != ""
140	if credErr != nil && !userPickedProvider {
141		other := "openai"
142		if provName == "openai" {
143			other = "anthropic"
144		}
145		if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
146			provName = other
147			cred, method, accountID, credErr = c, m, a, err
148		}
149	}
150
151	model := firstNonEmpty(args.Model, cfg.Model)
152	if model == "" {
153		if provName == "openai" {
154			model = "gpt-5"
155		} else {
156			model = provider.DefaultModel.ID
157		}
158	}
159	// If the resolved model belongs to a different provider (e.g. config
160	// says gpt-5 but we fell back to anthropic), pick that provider's default.
161	if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
162		if provName == "openai" {
163			model = "gpt-5"
164		} else {
165			model = provider.DefaultModel.ID
166		}
167	}
168	if _, err := provider.FindModel(provName, model); err != nil {
169		return Resolved{}, err
170	}
171
172	if credErr != nil && requireCred {
173		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
174			credErr, envVarName(provName))
175	}
176
177	sandbox := tools.NewSandbox(args.CWD)
178	reg := buildToolRegistry(args, args.CWD, sandbox)
179
180	// Skill discovery: scan project + global locations and, if any
181	// SKILL.md files were found, register the on-demand `skill`
182	// loader tool plus a system-prompt manifest so the model knows
183	// what's available.
184	homeDir, _ := os.UserHomeDir()
185	discovered, _ := skills.Discover(ZotHome(), args.CWD, homeDir)
186	var skillTool *skills.Tool
187	var skillAddendum string
188	if len(discovered) > 0 {
189		skillTool = skills.NewTool(discovered)
190		reg[skillTool.Name()] = skillTool
191		skillAddendum = skills.SystemPromptAddendum(discovered)
192	}
193	_ = skillTool
194
195	summaries := toolSummaries(reg, args)
196
197	append_ := append([]string(nil), args.AppendSystemPrompt...)
198	if skillAddendum != "" {
199		append_ = append(append_, skillAddendum)
200	}
201
202	sys := BuildSystemPrompt(SystemPromptOpts{
203		CWD:    args.CWD,
204		Tools:  summaries,
205		Custom: args.SystemPrompt,
206		Append: append_,
207	})
208
209	reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
210
211	max := args.MaxSteps
212	if max <= 0 {
213		max = 50
214	}
215
216	return Resolved{
217		Provider:         provName,
218		Model:            model,
219		Credential:       cred,
220		AuthMethod:       method,
221		AccountID:        accountID,
222		BaseURL:          args.BaseURL,
223		CWD:              args.CWD,
224		Reasoning:        reasoning,
225		ToolRegistry:     reg,
226		ToolSummary:      summaries,
227		SystemPrompt:     sys,
228		MaxSteps:         max,
229		Sandbox:          sandbox,
230		SkillTool:        skillTool,
231		systemAppend:     append_,
232		systemCustom:     args.SystemPrompt,
233		toolDescriptions: descMapFromSummaries(summaries),
234	}, nil
235}
236
237// descMapFromSummaries indexes the human-readable descriptions for
238// the renderToolsSection rebuild path.
239func descMapFromSummaries(summaries []ToolSummary) map[string]string {
240	out := make(map[string]string, len(summaries))
241	for _, s := range summaries {
242		out[s.Name] = s.Description
243	}
244	return out
245}
246
247// NewClient returns a provider.Client for r, choosing the auth mode
248// based on r.AuthMethod. Panics if no credential is present; callers
249// must check HasCredential() first.
250func (r Resolved) NewClient() provider.Client {
251	if !r.HasCredential() {
252		panic("NewClient called without credential; check HasCredential first")
253	}
254	if r.Provider == "openai" {
255		if r.AuthMethod == "oauth" {
256			return provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
257		}
258		return provider.NewOpenAI(r.Credential, r.BaseURL)
259	}
260	if r.AuthMethod == "oauth" {
261		return provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
262	}
263	return provider.NewAnthropic(r.Credential, r.BaseURL)
264}
265
266// UseSandbox replaces the sandbox pointer that every tool in r's
267// registry references. Used to keep the /lock state stable across
268// agent rebuilds (e.g. /login, /model switching providers).
269func (r *Resolved) UseSandbox(s *tools.Sandbox) {
270	if s == nil || r == nil {
271		return
272	}
273	r.Sandbox = s
274	for name, t := range r.ToolRegistry {
275		switch v := t.(type) {
276		case *tools.ReadTool:
277			v.Sandbox = s
278		case *tools.WriteTool:
279			v.Sandbox = s
280		case *tools.EditTool:
281			v.Sandbox = s
282		case *tools.BashTool:
283			v.Sandbox = s
284		}
285		_ = name
286	}
287}
288
289// NewAgent constructs a core.Agent from r. Requires a credential.
290func (r Resolved) NewAgent() *core.Agent {
291	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
292	a.MaxSteps = r.MaxSteps
293	a.Reasoning = r.Reasoning
294	return a
295}
296
297func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
298	if args.NoTools {
299		return core.Registry{}
300	}
301	all := map[string]core.Tool{
302		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
303		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
304		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
305		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
306	}
307	reg := core.Registry{}
308	if len(args.Tools) == 0 {
309		for _, t := range all {
310			reg[t.Name()] = t
311		}
312		return reg
313	}
314	for _, name := range args.Tools {
315		if t, ok := all[name]; ok {
316			reg[name] = t
317		}
318	}
319	return reg
320}
321
322func toolSummaries(reg core.Registry, args Args) []ToolSummary {
323	order := []string{"read", "write", "edit", "bash"}
324	var out []ToolSummary
325	for _, name := range order {
326		if t, ok := reg[name]; ok {
327			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
328		}
329	}
330	return out
331}
332
333func firstNonEmpty(vals ...string) string {
334	for _, v := range vals {
335		if v != "" {
336			return v
337		}
338	}
339	return ""
340}
341
342func envVarName(provider string) string {
343	switch provider {
344	case "openai":
345		return "OPENAI"
346	default:
347		return "ANTHROPIC"
348	}
349}

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 477

↑↓ moveenter openesc close