v0.1.71 9f0629b

build.go RawHistoryFolder

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

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