v0.1.79 7970412

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 != "deepseek" && 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("deepseek", ""); err == nil {
143			provName = "deepseek"
144		}
145		if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
146			provName = "anthropic"
147		}
148		// Reset the saved config so this doesn't keep happening.
149		cfg.Provider = provName
150		cfg.Model = ""
151		_ = SaveConfig(cfg)
152	}
153
154	var (
155		cred      string
156		method    string
157		accountID string
158		credErr   error
159	)
160	if provName == "ollama" {
161		if args.BaseURL == "" {
162			args.BaseURL = "http://localhost:11434"
163		}
164		cred = firstNonEmpty(args.APIKey, "ollama")
165		method = "apikey"
166	} else {
167		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
168	}
169
170	// If the user did NOT explicitly pick a provider and the default one
171	// has no credentials, auto-fall-back to whichever provider is actually
172	// logged in. That way running plain `zot` after `/login` (any provider)
173	// never shows a "not logged in" banner.
174	userPickedProvider := args.Provider != ""
175	if credErr != nil && !userPickedProvider && provName != "ollama" {
176		for _, other := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} {
177			if other == provName {
178				continue
179			}
180			if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
181				provName = other
182				cred, method, accountID, credErr = c, m, a, err
183				break
184			}
185		}
186	}
187
188	model := firstNonEmpty(args.Model, cfg.Model)
189	if model == "" {
190		switch provName {
191		case "openai":
192			model = "gpt-5"
193		case "kimi":
194			model = "kimi-for-coding"
195		case "deepseek":
196			model = "deepseek-v4-pro"
197		case "google":
198			model = "gemini-2.5-pro"
199		case "ollama":
200			return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
201		default:
202			model = provider.DefaultModel.ID
203		}
204	}
205	// If the resolved model belongs to a different provider (e.g. config
206	// says gpt-5 but we fell back to anthropic), pick that provider's default.
207	if provName != "ollama" {
208		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
209			switch provName {
210			case "openai":
211				model = "gpt-5"
212			case "kimi":
213				model = "kimi-for-coding"
214			case "deepseek":
215				model = "deepseek-v4-pro"
216			case "google":
217				model = "gemini-2.5-pro"
218			default:
219				model = provider.DefaultModel.ID
220			}
221		}
222	}
223	resolvedModel, err := provider.FindModel(provName, model)
224	if err != nil && provName != "ollama" {
225		return Resolved{}, err
226	}
227	if err != nil && provName == "ollama" {
228		resolvedModel = provider.Model{
229			Provider:      "ollama",
230			ID:            model,
231			DisplayName:   model,
232			ContextWindow: 32768,
233			MaxOutput:     8192,
234			BaseURL:       args.BaseURL,
235			Source:        "ollama",
236		}
237	}
238
239	// If the model defines a base URL (e.g. local ollama) and the
240	// user didn't pass --base-url, use the model's URL.
241	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
242		args.BaseURL = resolvedModel.BaseURL
243	}
244
245	// If the model has a base URL, credentials are optional (local
246	// models like ollama don't need real API keys).
247	if resolvedModel.BaseURL != "" && credErr != nil {
248		cred = "ollama"
249		credErr = nil
250		requireCred = false
251	}
252
253	if credErr != nil && requireCred {
254		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
255			credErr, envVarName(provName))
256	}
257
258	sandbox := tools.NewSandbox(args.CWD)
259	reg := buildToolRegistry(args, args.CWD, sandbox)
260
261	// Skill discovery: scan project + global locations + built-in
262	// skills shipped with the binary. If any are found, register
263	// the on-demand `skill` loader tool plus a system-prompt
264	// manifest so the model knows what's available.
265	//
266	// --no-skill bypasses the entire mechanism: no manifest in the
267	// system prompt, no `skill` tool in the registry. Useful for a
268	// clean-room run with zero extra context biasing the model.
269	var (
270		discovered    []*skills.Skill
271		skillTool     *skills.Tool
272		skillAddendum string
273	)
274	if !args.NoSkill {
275		homeDir, _ := os.UserHomeDir()
276		discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
277		if len(discovered) > 0 {
278			skillTool = skills.NewTool(discovered)
279			reg[skillTool.Name()] = skillTool
280			skillAddendum = skills.SystemPromptAddendum(discovered)
281		}
282	}
283	_ = skillTool
284
285	summaries := toolSummaries(reg, args)
286
287	append_ := append([]string(nil), args.AppendSystemPrompt...)
288	if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
289		append_ = append(append_, agentsAddendum)
290	}
291	if skillAddendum != "" {
292		append_ = append(append_, skillAddendum)
293	}
294
295	// Custom system prompt resolution order:
296	//   1. --system-prompt flag (highest priority; ad-hoc per run)
297	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
298	//   3. built-in default (defaultIdentity + defaultGuidelines)
299	custom := args.SystemPrompt
300	if custom == "" {
301		custom = readUserSystemPrompt(ZotHome())
302	}
303
304	sys := BuildSystemPrompt(SystemPromptOpts{
305		CWD:    args.CWD,
306		Tools:  summaries,
307		Custom: custom,
308		Append: append_,
309	})
310
311	reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
312
313	max := args.MaxSteps // 0 = unlimited
314
315	return Resolved{
316		Provider:         provName,
317		Model:            model,
318		Credential:       cred,
319		AuthMethod:       method,
320		AccountID:        accountID,
321		BaseURL:          args.BaseURL,
322		CWD:              args.CWD,
323		Reasoning:        reasoning,
324		ToolRegistry:     reg,
325		ToolSummary:      summaries,
326		SystemPrompt:     sys,
327		MaxSteps:         max,
328		Sandbox:          sandbox,
329		SkillTool:        skillTool,
330		systemAppend:     append_,
331		systemCustom:     custom,
332		toolDescriptions: descMapFromSummaries(summaries),
333	}, nil
334}
335
336// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
337// trimmed contents, or "" when the file is missing / unreadable /
338// empty. Errors are intentionally swallowed: the file is optional,
339// and any failure to read it should fall back to the built-in
340// default system prompt rather than crash the run.
341func readUserSystemPrompt(zotHome string) string {
342	if zotHome == "" {
343		return ""
344	}
345	path := filepath.Join(zotHome, "SYSTEM.md")
346	raw, err := os.ReadFile(path)
347	if err != nil {
348		return ""
349	}
350	return strings.TrimSpace(string(raw))
351}
352
353// readAgentsContext loads optional AGENTS.md instruction files. No
354// default file is created or required: zot only includes files that
355// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
356// followed by project instructions from the top-most parent down to cwd.
357func readAgentsContext(cwd, zotHome string) string {
358	type contextFile struct {
359		path    string
360		content string
361	}
362	var files []contextFile
363	seen := map[string]bool{}
364	add := func(path string) {
365		if path == "" {
366			return
367		}
368		abs, err := filepath.Abs(path)
369		if err == nil {
370			path = abs
371		}
372		if seen[path] {
373			return
374		}
375		raw, err := os.ReadFile(path)
376		if err != nil {
377			return
378		}
379		content := strings.TrimSpace(string(raw))
380		if content == "" {
381			return
382		}
383		seen[path] = true
384		files = append(files, contextFile{path: path, content: content})
385	}
386	addFirstFromDir := func(dir string) {
387		if dir == "" {
388			return
389		}
390		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
391			path := filepath.Join(dir, name)
392			if _, err := os.Stat(path); err == nil {
393				add(path)
394				return
395			}
396		}
397	}
398
399	addFirstFromDir(zotHome)
400
401	if cwd != "" {
402		abs, err := filepath.Abs(cwd)
403		if err == nil {
404			cwd = abs
405		}
406		var dirs []string
407		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
408			dirs = append(dirs, dir)
409			parent := filepath.Dir(dir)
410			if parent == dir {
411				break
412			}
413		}
414		for i := len(dirs) - 1; i >= 0; i-- {
415			addFirstFromDir(dirs[i])
416		}
417	}
418
419	if len(files) == 0 {
420		return ""
421	}
422	var sb strings.Builder
423	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")
424	for _, f := range files {
425		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
426	}
427	return strings.TrimSpace(sb.String())
428}
429
430// descMapFromSummaries indexes the human-readable descriptions for
431// the renderToolsSection rebuild path.
432func descMapFromSummaries(summaries []ToolSummary) map[string]string {
433	out := make(map[string]string, len(summaries))
434	for _, s := range summaries {
435		out[s.Name] = s.Description
436	}
437	return out
438}
439
440// NewClient returns a provider.Client for r, choosing the auth mode
441// based on r.AuthMethod. Panics if no credential is present; callers
442// must check HasCredential() first.
443func (r Resolved) NewClient() provider.Client {
444	if !r.HasCredential() {
445		panic("NewClient called without credential; check HasCredential first")
446	}
447	switch r.Provider {
448	case "ollama":
449		return provider.NewOpenAI(r.Credential, r.BaseURL)
450	case "kimi":
451		inner := provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
452		if r.AuthMethod == "oauth" {
453			return r.wrapWithRefresh(inner)
454		}
455		return inner
456	case "deepseek":
457		return provider.NewDeepSeek(r.Credential, r.BaseURL)
458	case "openai":
459		if r.AuthMethod == "oauth" {
460			inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
461			return r.wrapWithRefresh(inner)
462		}
463		return provider.NewOpenAI(r.Credential, r.BaseURL)
464	case "google":
465		// API-key only path. Gemini Generative Language API.
466		return provider.NewGemini(r.Credential, r.BaseURL)
467	default:
468		if r.AuthMethod == "oauth" {
469			inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
470			return r.wrapWithRefresh(inner)
471		}
472		return provider.NewAnthropic(r.Credential, r.BaseURL)
473	}
474}
475
476// wrapWithRefresh wraps an OAuth client so the access token is
477// refreshed automatically before each API call. Without this, long
478// sessions (hours) silently fail when the 1-hour token expires.
479func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
480	provName := r.Provider
481	baseURL := r.BaseURL
482	accountID := r.AccountID
483
484	refreshFn := func(ctx context.Context) (string, error) {
485		tok, err := refreshIfExpired(provName, loadOAuthToken(provName))
486		if err != nil {
487			return "", err
488		}
489		return tok.AccessToken, nil
490	}
491
492	factory := func(token string) provider.Client {
493		switch provName {
494		case "openai":
495			return provider.NewOpenAICodex(token, accountID, baseURL)
496		case "kimi":
497			return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders())
498		default:
499			return provider.NewAnthropicOAuth(token, baseURL)
500		}
501	}
502
503	return provider.NewRefreshingClient(inner, refreshFn, factory)
504}
505
506// UseSandbox replaces the sandbox pointer that every tool in r's
507// registry references. Used to keep the /jail state stable across
508// agent rebuilds (e.g. /login, /model switching providers).
509func (r *Resolved) UseSandbox(s *tools.Sandbox) {
510	if s == nil || r == nil {
511		return
512	}
513	r.Sandbox = s
514	for name, t := range r.ToolRegistry {
515		switch v := t.(type) {
516		case *tools.ReadTool:
517			v.Sandbox = s
518		case *tools.WriteTool:
519			v.Sandbox = s
520		case *tools.EditTool:
521			v.Sandbox = s
522		case *tools.BashTool:
523			v.Sandbox = s
524		}
525		_ = name
526	}
527}
528
529// NewAgent constructs a core.Agent from r. Requires a credential.
530func (r Resolved) NewAgent() *core.Agent {
531	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
532	a.MaxSteps = r.MaxSteps
533	a.Reasoning = r.Reasoning
534	return a
535}
536
537func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
538	if args.NoTools {
539		return core.Registry{}
540	}
541	all := map[string]core.Tool{
542		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
543		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
544		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
545		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
546	}
547	reg := core.Registry{}
548	if len(args.Tools) == 0 {
549		for _, t := range all {
550			reg[t.Name()] = t
551		}
552		return reg
553	}
554	for _, name := range args.Tools {
555		if t, ok := all[name]; ok {
556			reg[name] = t
557		}
558	}
559	return reg
560}
561
562func toolSummaries(reg core.Registry, args Args) []ToolSummary {
563	order := []string{"read", "write", "edit", "bash"}
564	var out []ToolSummary
565	for _, name := range order {
566		if t, ok := reg[name]; ok {
567			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
568		}
569	}
570	return out
571}
572
573func firstNonEmpty(vals ...string) string {
574	for _, v := range vals {
575		if v != "" {
576			return v
577		}
578	}
579	return ""
580}
581
582func kimiCodeHeaders() map[string]string {
583	host, _ := os.Hostname()
584	if host == "" {
585		host = "unknown"
586	}
587	deviceID := ""
588	if home, err := os.UserHomeDir(); err == nil {
589		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
590			deviceID = strings.TrimSpace(string(b))
591		}
592	}
593	if deviceID == "" {
594		deviceID = "zot"
595	}
596	return map[string]string{
597		"User-Agent":         "KimiCLI/1.41.0",
598		"X-Msh-Platform":     "kimi_cli",
599		"X-Msh-Version":      "1.41.0",
600		"X-Msh-Device-Name":  host,
601		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
602		"X-Msh-Os-Version":   runtime.GOOS,
603		"X-Msh-Device-Id":    deviceID,
604	}
605}
606
607func envVarName(provider string) string {
608	switch provider {
609	case "openai":
610		return "OPENAI"
611	case "kimi":
612		return "KIMI"
613	case "deepseek":
614		return "DEEPSEEK"
615	case "google":
616		return "GEMINI"
617	case "ollama":
618		return "OLLAMA"
619	default:
620		return "ANTHROPIC"
621	}
622}

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