v0.1.8 b3a935f

build.go RawHistoryFolder

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

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