v0.2.97 d25ea2b

build.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"runtime"
9	"strings"
10
11	zotdocs "github.com/patriceckhart/zot"
12	"github.com/patriceckhart/zot/packages/agent/skills"
13	"github.com/patriceckhart/zot/packages/agent/tools"
14	"github.com/patriceckhart/zot/packages/core"
15	"github.com/patriceckhart/zot/packages/provider"
16)
17
18// Resolved is the effective configuration after merging CLI, config, defaults.
19type Resolved struct {
20	Provider    string
21	Model       string
22	Credential  string // api key or oauth access token
23	AuthMethod  string // "apikey" | "oauth" | "" (no credential yet)
24	AccountID   string // ChatGPT account id (for openai oauth), "" otherwise
25	BaseURL     string
26	InsecureTLS bool
27	CWD         string
28	Reasoning   string
29	Temperature *float32
30
31	ToolRegistry core.Registry
32	ToolSummary  []ToolSummary
33	SystemPrompt string
34	MaxSteps     int
35	Sandbox      *tools.Sandbox
36
37	// MaxOutput is the resolved model's maximum output-token budget
38	// (from the catalog). Passed to the agent so each turn requests
39	// the model's full output capacity instead of the provider's
40	// conservative default (e.g. Bedrock's 4096, which truncates
41	// long writes/edits with stopReason=length).
42	MaxOutput int
43
44	// SkillTool is the on-demand skill loader registered with the
45	// agent's tool registry, or nil if no SKILL.md files were
46	// discovered. Exposed so the tui can list / preview skills.
47	SkillTool *skills.Tool
48
49	// Bookkeeping for MergeExtensionTools. Captured at Resolve time
50	// so the system prompt can be rebuilt later without re-running
51	// resolve.
52	systemAppend     []string
53	systemCustom     string
54	toolDescriptions map[string]string
55}
56
57// HasCredential reports whether a credential was resolved.
58func (r Resolved) HasCredential() bool { return r.Credential != "" }
59
60// MergeExtensionTools folds every tool registered by an extension
61// into r's ToolRegistry and re-renders the system prompt's tool
62// summary so the model sees both built-in and extension tools.
63//
64// Idempotent: calling twice with the same manager state has no
65// effect on the second pass (existing names are preserved). Built-in
66// tools always win on conflict.
67func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
68	if mgr == nil {
69		return
70	}
71	infos := mgr.Tools()
72	if len(infos) == 0 {
73		return
74	}
75	changed := false
76	for _, info := range infos {
77		if _, exists := r.ToolRegistry[info.Name]; exists {
78			continue
79		}
80		r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
81		changed = true
82	}
83	if !changed {
84		return
85	}
86	// Re-render the system prompt with the merged tool list. Skill
87	// addendum is preserved by walking the existing append slice.
88	append_ := r.systemAppend
89	r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
90		CWD:        r.CWD,
91		Tools:      toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
92		Custom:     r.systemCustom,
93		Append:     append_,
94		ZotDocsDir: filepath.Join(ZotHome(), "docs"),
95	})
96}
97
98// ExtensionToolSource is the slice of the extension manager that
99// MergeExtensionTools needs. Lives here as an interface so the
100// build package doesn't import packages/agent/extensions (which
101// imports core, which imports... avoid the cycle).
102type ExtensionToolSource interface {
103	Tools() []ExtensionToolInfo
104	NewExtensionTool(info ExtensionToolInfo) core.Tool
105}
106
107// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
108// ExtensionToolSource here without importing the extensions
109// package. The cli wires a tiny adapter to bridge them.
110type ExtensionToolInfo struct {
111	Extension   string
112	Name        string
113	Description string
114	Schema      []byte
115	Deferred    bool
116}
117
118// toolSummariesFromRegistry rebuilds the system-prompt tool list
119// from a (possibly extended) registry, using cached descriptions for
120// the human-readable summary text.
121func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
122	out := make([]ToolSummary, 0, len(reg))
123	for name, t := range reg {
124		if d, ok := t.(interface{ Deferred() bool }); ok && d.Deferred() {
125			continue
126		}
127		desc := t.Description()
128		if d, ok := cached[name]; ok && d != "" {
129			desc = d
130		}
131		out = append(out, ToolSummary{Name: name, Description: desc})
132	}
133	return out
134}
135
136// defaultModelForProvider returns the model id zot prefers when the
137// caller didn't pick one. Mirrors the per-provider switch used at
138// multiple points in Resolve; centralised so the unknown-model
139// recovery path and the no-model-configured path can't drift.
140//
141// Returns the empty string for "ollama", which has no built-in
142// default — the caller is expected to special-case ollama and
143// error or use whatever the user passed.
144func defaultModelForProvider(prov string) string {
145	switch prov {
146	case "openai":
147		return "gpt-5"
148	case "openai-codex":
149		return "gpt-5.5"
150	case "openai-responses":
151		return "gpt-5"
152	case "kimi":
153		return "kimi-for-coding"
154	case "deepseek":
155		return "deepseek-v4-pro"
156	case "google":
157		return "gemini-2.5-pro"
158	case "ollama":
159		return ""
160	case "moonshotai", "moonshotai-cn":
161		return "kimi-k2.6"
162	case "cerebras":
163		return "qwen-3-235b-a22b-instruct-2507"
164	case "groq":
165		return "llama-3.3-70b-versatile"
166	case "xai":
167		return "grok-4.5"
168	case "together":
169		return "Qwen/Qwen3-Coder-480B-A35B-Instruct"
170	case "huggingface":
171		return "moonshotai/Kimi-K2-Instruct"
172	case "openrouter":
173		return "anthropic/claude-sonnet-4.5"
174	case "mistral":
175		return "mistral-large-latest"
176	case "zai":
177		return "glm-4.7"
178	case "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp":
179		return "mimo-v2.5"
180	case "minimax", "minimax-cn":
181		return "MiniMax-M2.7"
182	case "fireworks":
183		return "accounts/fireworks/models/kimi-k2p6"
184	case "vercel-ai-gateway":
185		return "anthropic/claude-sonnet-4.5"
186	case "opencode":
187		return "claude-sonnet-4-5"
188	case "opencode-go":
189		return "kimi-k2.6"
190	case "amazon-bedrock":
191		return "anthropic.claude-sonnet-4-5-20250929-v1:0"
192	case "google-vertex":
193		return "gemini-2.5-pro"
194	case "azure-openai-responses":
195		return "gpt-5"
196	case "github-copilot":
197		return "claude-sonnet-4.5"
198	default:
199		// Custom providers: pick the first model from the catalog for
200		// that provider, or fall back to the global default.
201		if models := provider.ModelsForProvider(prov); len(models) > 0 {
202			return models[0].ID
203		}
204		return provider.DefaultModel.ID
205	}
206}
207
208// knownProviders is the set of provider ids zot recognises. Used by
209// Resolve to validate args.Provider, by extension-callers, and by the
210// auto-fallback logic that picks any logged-in provider when the user's
211// preferred one has no credentials.
212var knownProviders = []string{
213	"anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama",
214	"moonshotai", "moonshotai-cn",
215	"cerebras", "groq", "xai", "together", "huggingface", "openrouter",
216	"mistral", "zai",
217	"xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
218	"minimax", "minimax-cn",
219	"fireworks", "vercel-ai-gateway",
220	"opencode", "opencode-go",
221	"amazon-bedrock", "google-vertex", "azure-openai-responses",
222	"github-copilot", "cloudflare-workers-ai", "cloudflare-ai-gateway",
223}
224
225func isKnownProvider(name string) bool {
226	for _, p := range knownProviders {
227		if p == name {
228			return true
229		}
230	}
231	_, ok := provider.CustomProviders()[name]
232	return ok
233}
234
235// isBuiltinProvider reports whether name is in the hardcoded
236// knownProviders list (not a user-defined custom provider).
237func isBuiltinProvider(name string) bool {
238	for _, p := range knownProviders {
239		if p == name {
240			return true
241		}
242	}
243	return false
244}
245
246// providerAliases maps common short / alternate provider names to the
247// canonical id in knownProviders. Users (and other agents) reach for
248// "bedrock" or "vertex" far more naturally than the fully-qualified
249// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
250// treated as an unknown provider and Resolve silently falls back to
251// anthropic, producing a misleading "no credential for anthropic" error
252// after the user explicitly picked, say, bedrock.
253var providerAliases = map[string]string{
254	"bedrock":      "amazon-bedrock",
255	"aws-bedrock":  "amazon-bedrock",
256	"amazon":       "amazon-bedrock",
257	"vertex":       "google-vertex",
258	"gcp-vertex":   "google-vertex",
259	"gemini":       "google",
260	"googleai":     "google",
261	"google-ai":    "google",
262	"azure":        "azure-openai-responses",
263	"azure-openai": "azure-openai-responses",
264	"copilot":      "github-copilot",
265	"github":       "github-copilot",
266	"codex":        "openai-codex",
267	"moonshot":     "moonshotai",
268	"kimi-code":    "kimi",
269	"ai-gateway":   "vercel-ai-gateway",
270	"vercel":       "vercel-ai-gateway",
271	"cloudflare":   "cloudflare-workers-ai",
272	"workers-ai":   "cloudflare-workers-ai",
273	"hf":           "huggingface",
274}
275
276// canonicalProvider normalises a user-supplied provider name: trims
277// surrounding whitespace, lower-cases it, and resolves any known alias
278// to its canonical id. Unknown names are returned trimmed/lower-cased
279// and unchanged so the existing unknown-provider handling still runs.
280func canonicalProvider(name string) string {
281	n := strings.ToLower(strings.TrimSpace(name))
282	if n == "" {
283		return n
284	}
285	if canon, ok := providerAliases[n]; ok {
286		return canon
287	}
288	return n
289}
290
291// Resolve merges args, config, and env into a Resolved set.
292//
293// Unlike the earlier version, Resolve NEVER returns an error for
294// missing credentials: the TUI can start without them and launch a
295// login flow. requireCred controls whether missing credentials are a
296// hard error (used by print/json modes).
297func Resolve(args Args, requireCred bool) (Resolved, error) {
298	cfg, _ := LoadConfig()
299
300	// User-requested provider (explicit > config > default).
301	// Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
302	// before validation so an alias is never mistaken for an unknown
303	// provider and silently downgraded to anthropic.
304	argProvider := canonicalProvider(args.Provider)
305	provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
306	if !isKnownProvider(provName) {
307		// Unknown provider (maybe removed or renamed). Fall back to
308		// the first provider that has credentials, or anthropic.
309		// Custom providers (from models.json) are already accepted
310		// by isKnownProvider, so we only reach here for truly unknown
311		// names.
312		provName = "anthropic"
313		if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
314			provName = "openai"
315		}
316		if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil {
317			provName = "openai-codex"
318		}
319		if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
320			provName = "kimi"
321		}
322		if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil {
323			provName = "deepseek"
324		}
325		if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
326			provName = "anthropic"
327		}
328		// Reset the saved config so this doesn't keep happening.
329		cfg.Provider = provName
330		cfg.Model = ""
331		_ = SaveConfig(cfg)
332	}
333
334	var (
335		cred      string
336		method    string
337		accountID string
338		credErr   error
339	)
340	if provName == "ollama" {
341		cred = firstNonEmpty(args.APIKey, "ollama")
342		method = "apikey"
343	} else {
344		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
345	}
346
347	// Persist --api-key for custom providers so subsequent runs don't
348	// need to pass it again.
349	if !isBuiltinProvider(provName) && args.APIKey != "" {
350		if store := AuthStoreFor(); store != nil {
351			_ = store.SetAPIKey(provName, args.APIKey)
352		}
353	}
354
355	// If the user did NOT explicitly pick a provider (neither via --provider
356	// nor by saving one in config.json) and the default one has no
357	// credentials, auto-fall-back to whichever provider is actually logged
358	// in. That way running plain `zot` after `/login` (any provider) never
359	// shows a "not logged in" banner.
360	//
361	// Critical: when the user HAS saved a provider in config.json (e.g.
362	// "deepseek" with "deepseek-v4-flash"), that choice is respected even
363	// if no credential is found — the tui will show the login dialog or
364	// surface a clear credential error rather than silently switching to
365	// a completely different provider and model.
366	// autoFellBack tracks whether the provider was changed by the
367	// auto-fallback loop (no explicit provider from CLI or config,
368	// and the default had no credentials). When false, the provider
369	// came from the user's explicit choice (--provider or config.json)
370	// and must be respected, including any model id that may belong
371	// to a different provider in the catalog — for example when using
372	// a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash".
373	var autoFellBack bool
374	userPickedProvider := args.Provider != "" || cfg.Provider != ""
375	if credErr != nil && !userPickedProvider && provName != "ollama" {
376		autoFellBack = true
377		// Scan every known provider (not a hardcoded subset) so any
378		// env-based credential is discovered, e.g. an env-only
379		// amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
380		// IAM keys) when no config.json pins the provider, such as after
381		// pointing ZOT_HOME at a fresh home dir. Iteration order of
382		// knownProviders defines fallback priority. ollama is skipped:
383		// it has no credential and would always "match".
384		for _, other := range knownProviders {
385			if other == provName || other == "ollama" {
386				continue
387			}
388			if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
389				provName = other
390				cred, method, accountID, credErr = c, m, a, err
391				break
392			}
393		}
394	}
395
396	model := firstNonEmpty(args.Model, cfg.Model)
397	if model == "" {
398		if provName == "ollama" {
399			return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
400		}
401		model = defaultModelForProvider(provName)
402	}
403	// If the resolved model belongs to a different provider (e.g. config
404	// says gpt-5 but we auto-fell back to anthropic), pick that provider's
405	// default. This override only fires when the provider was auto-fallback-
406	// changed, NOT when the user explicitly configured the pair — gateway/
407	// router providers (openrouter, vercel-ai-gateway, etc.) can serve
408	// models from any provider in the catalog.
409	if autoFellBack && provName != "ollama" {
410		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
411			model = defaultModelForProvider(provName)
412		}
413	}
414	resolvedModel, err := provider.FindModel(provName, model)
415	if err != nil && provName == "ollama" {
416		// ollama is intentionally open-catalogue: any model id the
417		// local server understands is valid, even if not in the
418		// baked-in catalog.
419		resolvedModel = provider.Model{
420			Provider:      "ollama",
421			ID:            model,
422			DisplayName:   model,
423			ContextWindow: 32768,
424			MaxOutput:     8192,
425			BaseURL:       args.BaseURL,
426			Source:        "ollama",
427		}
428		err = nil
429	}
430	// Custom providers are open-catalogue like ollama: any model id the
431	// endpoint understands is valid. Use the provider-level base URL.
432	if err != nil {
433		if cfg, ok := provider.CustomProviders()[provName]; ok {
434			resolvedModel = provider.Model{
435				Provider:    provName,
436				ID:          model,
437				DisplayName: model,
438				BaseURL:     cfg.BaseURL,
439				Source:      "user",
440			}
441			err = nil
442		}
443	}
444	if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
445		// Fall back to the provider-level base URL when the model does
446		// not define its own endpoint.
447		resolvedModel.BaseURL = cfg.BaseURL
448	}
449	if err != nil {
450		// The model the user (or persisted config) asked for is no
451		// longer in the active catalogue — they probably removed it
452		// from their models.json or upgraded zot and the id changed.
453		// Refusing to launch is the wrong move: it strands the user
454		// with no way to even open the TUI and pick a new model.
455		// Fall back to the provider's default, warn on stderr, and,
456		// when the stale id came from the persisted config (not an
457		// explicit --model flag), repair the config so the warning
458		// doesn't repeat on every launch.
459
460		// Gateway providers can accept route-qualified ids that are not in
461		// zot's local catalog yet, for example OpenRouter's
462		// "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
463		// plain unknown values are likely typos and should still fall back.
464		if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
465			resolvedModel = provider.Model{
466				Provider:      provName,
467				ID:            model,
468				DisplayName:   model,
469				ContextWindow: 1000000,
470				MaxOutput:     64000,
471				BaseURL:       "",
472				Source:        "gateway",
473			}
474			err = nil
475			// Don't repair config — the routed model id may be valid upstream.
476		} else {
477			fallback := defaultModelForProvider(provName)
478			fm, ferr := provider.FindModel(provName, fallback)
479			if ferr != nil {
480				// Even the provider default is gone (catastrophic
481				// catalogue trim). Last resort: any model on this
482				// provider, then the global DefaultModel.
483				if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
484					fm = candidates[0]
485					ferr = nil
486				} else {
487					fm = provider.DefaultModel
488					ferr = nil
489				}
490			}
491			fmt.Fprintf(os.Stderr,
492				"zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
493				model, fm.ID)
494			if args.Model == "" && cfg.Model == model {
495				cfg.Model = fm.ID
496				_ = SaveConfig(cfg)
497			}
498			resolvedModel = fm
499			model = fm.ID
500		}
501	}
502
503	explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
504
505	// If the model defines a base URL (e.g. local ollama) and the
506	// user didn't pass --base-url, use the model's URL. For ollama,
507	// keep http://localhost:11434 as a fallback only after the model
508	// metadata has had a chance to provide a custom baseUrl.
509	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
510		args.BaseURL = resolvedModel.BaseURL
511	}
512	if args.BaseURL == "" && provName == "ollama" {
513		args.BaseURL = "http://localhost:11434"
514	}
515
516	// Insecure TLS is intentionally scoped to explicit custom endpoints.
517	// Built-in provider base URLs, auth calls, and model discovery keep normal
518	// certificate verification even when --insecure is present.
519	insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
520
521	// If the model has a base URL, credentials are optional (local
522	// models like ollama don't need real API keys).
523	if resolvedModel.BaseURL != "" && credErr != nil {
524		cred = "ollama"
525		credErr = nil
526		requireCred = false
527	}
528
529	if credErr != nil && requireCred {
530		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
531			credErr, envVarName(provName))
532	}
533
534	sandbox := tools.NewSandbox(args.CWD)
535	if cfg.JailByDefault != nil && *cfg.JailByDefault {
536		sandbox.Lock()
537	}
538	if args.PermissionSet != nil {
539		sandbox.SetPermissions(args.PermissionSet)
540	}
541	reg := buildToolRegistry(args, args.CWD, sandbox)
542
543	docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
544
545	// Skill discovery: scan project + global locations + built-in
546	// skills shipped with the binary. If any are found, register
547	// the on-demand `skill` loader tool plus a system-prompt
548	// manifest so the model knows what's available.
549	//
550	// --no-skill bypasses the entire mechanism: no manifest in the
551	// system prompt, no `skill` tool in the registry. Useful for a
552	// clean-room run with zero extra context biasing the model.
553	var (
554		discovered    []*skills.Skill
555		skillTool     *skills.Tool
556		skillAddendum string
557	)
558	if !args.NoSkill {
559		homeDir, _ := os.UserHomeDir()
560		discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
561		if len(discovered) > 0 {
562			skillTool = skills.NewTool(discovered)
563			reg[skillTool.Name()] = skillTool
564			skillAddendum = skills.SystemPromptAddendum(discovered)
565		}
566	}
567	_ = skillTool
568
569	summaries := toolSummaries(reg, args)
570
571	append_ := append([]string(nil), args.AppendSystemPrompt...)
572	if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
573		append_ = append(append_, agentsAddendum)
574	}
575	if skillAddendum != "" {
576		append_ = append(append_, skillAddendum)
577	}
578	if AutoSwarmEnabled() {
579		append_ = append(append_, AutoSwarmSystemAddendum)
580	}
581
582	// Custom system prompt resolution order:
583	//   1. --system-prompt flag (highest priority; ad-hoc per run)
584	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
585	//   3. built-in default (defaultIdentity + defaultGuidelines)
586	custom := args.SystemPrompt
587	if custom == "" {
588		custom = readUserSystemPrompt(ZotHome())
589	}
590
591	sys := BuildSystemPrompt(SystemPromptOpts{
592		CWD:        args.CWD,
593		Tools:      summaries,
594		Custom:     custom,
595		Append:     append_,
596		ZotDocsDir: docsDir,
597	})
598
599	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
600	temperature := args.Temperature
601	if temperature == nil {
602		temperature = cfg.Temperature
603	}
604
605	max := args.MaxSteps // 0 = unlimited
606
607	return Resolved{
608		Provider:         provName,
609		Model:            model,
610		Credential:       cred,
611		AuthMethod:       method,
612		AccountID:        accountID,
613		BaseURL:          args.BaseURL,
614		InsecureTLS:      insecureTLS,
615		CWD:              args.CWD,
616		Reasoning:        reasoning,
617		Temperature:      temperature,
618		ToolRegistry:     reg,
619		ToolSummary:      summaries,
620		SystemPrompt:     sys,
621		MaxSteps:         max,
622		MaxOutput:        resolvedModel.MaxOutput,
623		Sandbox:          sandbox,
624		SkillTool:        skillTool,
625		systemAppend:     append_,
626		systemCustom:     custom,
627		toolDescriptions: descMapFromSummaries(summaries),
628	}, nil
629}
630
631// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
632// trimmed contents, or "" when the file is missing / unreadable /
633// empty. Errors are intentionally swallowed: the file is optional,
634// and any failure to read it should fall back to the built-in
635// default system prompt rather than crash the run.
636func readUserSystemPrompt(zotHome string) string {
637	if zotHome == "" {
638		return ""
639	}
640	path := filepath.Join(zotHome, "SYSTEM.md")
641	raw, err := os.ReadFile(path)
642	if err != nil {
643		return ""
644	}
645	return strings.TrimSpace(string(raw))
646}
647
648// readAgentsContext loads optional AGENTS.md instruction files. No
649// default file is created or required: zot only includes files that
650// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
651// followed by project instructions from the top-most parent down to cwd.
652func readAgentsContext(cwd, zotHome string) string {
653	type contextFile struct {
654		path    string
655		content string
656	}
657	var files []contextFile
658	seen := map[string]bool{}
659	add := func(path string) {
660		if path == "" {
661			return
662		}
663		abs, err := filepath.Abs(path)
664		if err == nil {
665			path = abs
666		}
667		if seen[path] {
668			return
669		}
670		raw, err := os.ReadFile(path)
671		if err != nil {
672			return
673		}
674		content := strings.TrimSpace(string(raw))
675		if content == "" {
676			return
677		}
678		seen[path] = true
679		files = append(files, contextFile{path: path, content: content})
680	}
681	addFirstFromDir := func(dir string) {
682		if dir == "" {
683			return
684		}
685		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
686			path := filepath.Join(dir, name)
687			if _, err := os.Stat(path); err == nil {
688				add(path)
689				return
690			}
691		}
692	}
693
694	addFirstFromDir(zotHome)
695
696	if cwd != "" {
697		abs, err := filepath.Abs(cwd)
698		if err == nil {
699			cwd = abs
700		}
701		var dirs []string
702		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
703			dirs = append(dirs, dir)
704			parent := filepath.Dir(dir)
705			if parent == dir {
706				break
707			}
708		}
709		for i := len(dirs) - 1; i >= 0; i-- {
710			addFirstFromDir(dirs[i])
711		}
712	}
713
714	if len(files) == 0 {
715		return ""
716	}
717	var sb strings.Builder
718	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")
719	for _, f := range files {
720		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
721	}
722	return strings.TrimSpace(sb.String())
723}
724
725// descMapFromSummaries indexes the human-readable descriptions for
726// the renderToolsSection rebuild path.
727func descMapFromSummaries(summaries []ToolSummary) map[string]string {
728	out := make(map[string]string, len(summaries))
729	for _, s := range summaries {
730		out[s.Name] = s.Description
731	}
732	return out
733}
734
735// NewClient returns a provider.Client for r, choosing the auth mode
736// based on r.AuthMethod. Panics if no credential is present; callers
737// must check HasCredential() first.
738func (r Resolved) NewClient() provider.Client {
739	if !r.HasCredential() {
740		panic("NewClient called without credential; check HasCredential first")
741	}
742	wrap := r.withHTTPClient
743	switch r.Provider {
744	case "ollama":
745		return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
746	case "kimi":
747		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
748		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
749		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
750		if r.AuthMethod == "oauth" {
751			return r.wrapWithRefresh(inner)
752		}
753		return inner
754	case "moonshotai":
755		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
756	case "moonshotai-cn":
757		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
758	case "deepseek":
759		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
760	case "openai":
761		return provider.NewModelRouter("openai",
762			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
763			map[string]provider.Client{
764				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
765			})
766	case "openai-codex":
767		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
768		return r.wrapWithRefresh(inner)
769	case "openai-responses":
770		// Public OpenAI Responses API (api.openai.com/v1/responses) via
771		// API key. Separate provider from `openai` (Chat Completions) and
772		// from `openai-codex` (ChatGPT subscription OAuth).
773		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
774	case "google":
775		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
776	case "cerebras":
777		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
778	case "groq":
779		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
780	case "xai":
781		inner := r.newXAIClient(r.Credential)
782		if r.AuthMethod == "oauth" {
783			return r.wrapWithRefresh(inner)
784		}
785		return inner
786	case "together":
787		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
788	case "huggingface":
789		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
790	case "openrouter":
791		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
792	case "zai":
793		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
794	case "xiaomi":
795		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
796	case "xiaomi-token-plan-ams":
797		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
798	case "xiaomi-token-plan-cn":
799		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
800	case "xiaomi-token-plan-sgp":
801		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
802	case "opencode":
803		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
804	case "opencode-go":
805		return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
806	case "minimax":
807		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
808	case "minimax-cn":
809		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
810	case "fireworks":
811		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
812	case "vercel-ai-gateway":
813		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
814	case "mistral":
815		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
816	case "amazon-bedrock":
817		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
818	case "google-vertex":
819		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
820	case "azure-openai-responses":
821		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
822	case "github-copilot":
823		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
824	case "cloudflare-workers-ai":
825		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
826	case "cloudflare-ai-gateway":
827		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
828	default:
829		// Custom providers: choose wire format from the models.json api field.
830		if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
831			switch cfg.API {
832			case "anthropic":
833				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
834			default: // "openai"
835				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
836			}
837		}
838		if r.AuthMethod == "oauth" {
839			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
840			return r.wrapWithRefresh(inner)
841		}
842		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
843	}
844}
845
846func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
847	if !r.InsecureTLS {
848		return c
849	}
850	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
851}
852
853func (r Resolved) newXAIClient(token string) provider.Client {
854	return provider.NewModelRouter("xai",
855		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
856		map[string]provider.Client{
857			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
858		})
859}
860
861// wrapWithRefresh wraps an OAuth client so the access token is
862// refreshed automatically before each API call. Without this, long
863// sessions (hours) silently fail when the 1-hour token expires.
864func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
865	provName := r.Provider
866	tokenProvider := provName
867	if provName == "openai-codex" {
868		tokenProvider = "openai"
869	}
870	baseURL := r.BaseURL
871	accountID := r.AccountID
872
873	refreshFn := func(ctx context.Context) (string, error) {
874		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
875		if err != nil {
876			return "", err
877		}
878		return tok.AccessToken, nil
879	}
880
881	factory := func(token string) provider.Client {
882		switch provName {
883		case "openai-codex":
884			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
885		case "kimi":
886			// anthropic-messages on api.kimi.com/coding.
887			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
888		case "xai":
889			return r.newXAIClient(token)
890		default:
891			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
892		}
893	}
894
895	return provider.NewRefreshingClient(inner, refreshFn, factory)
896}
897
898// UseSandbox replaces the sandbox pointer that every tool in r's
899// registry references. Used to keep the /jail state stable across
900// agent rebuilds (e.g. /login, /model switching providers).
901func (r *Resolved) UseSandbox(s *tools.Sandbox) {
902	if s == nil || r == nil {
903		return
904	}
905	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
906		s.Permissions = r.Sandbox.Permissions
907	}
908	r.Sandbox = s
909	for name, t := range r.ToolRegistry {
910		switch v := t.(type) {
911		case *tools.ReadTool:
912			v.Sandbox = s
913		case *tools.WriteTool:
914			v.Sandbox = s
915		case *tools.EditTool:
916			v.Sandbox = s
917		case *tools.BashTool:
918			v.Sandbox = s
919		}
920		_ = name
921	}
922}
923
924// NewAgent constructs a core.Agent from r. Requires a credential.
925func (r Resolved) NewAgent() *core.Agent {
926	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
927	a.MaxSteps = r.MaxSteps
928	a.MaxTokens = r.MaxOutput
929	a.Reasoning = r.Reasoning
930	a.Temperature = r.Temperature
931	return a
932}
933
934func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
935	if args.NoTools {
936		return core.Registry{}
937	}
938	all := map[string]core.Tool{
939		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
940		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
941		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
942		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
943	}
944	reg := core.Registry{}
945	if len(args.Tools) == 0 {
946		for _, t := range all {
947			reg[t.Name()] = t
948		}
949		return reg
950	}
951	for _, name := range args.Tools {
952		if t, ok := all[name]; ok {
953			reg[name] = t
954		}
955	}
956	return reg
957}
958
959func toolSummaries(reg core.Registry, args Args) []ToolSummary {
960	order := []string{"read", "write", "edit", "bash"}
961	var out []ToolSummary
962	for _, name := range order {
963		if t, ok := reg[name]; ok {
964			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
965		}
966	}
967	return out
968}
969
970func firstNonEmpty(vals ...string) string {
971	for _, v := range vals {
972		if v != "" {
973			return v
974		}
975	}
976	return ""
977}
978
979func kimiCodeHeaders() map[string]string {
980	host, _ := os.Hostname()
981	if host == "" {
982		host = "unknown"
983	}
984	deviceID := ""
985	if home, err := os.UserHomeDir(); err == nil {
986		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
987			deviceID = strings.TrimSpace(string(b))
988		}
989	}
990	if deviceID == "" {
991		deviceID = "zot"
992	}
993	return map[string]string{
994		"User-Agent":         "KimiCLI/1.41.0",
995		"X-Msh-Platform":     "kimi_cli",
996		"X-Msh-Version":      "1.41.0",
997		"X-Msh-Device-Name":  host,
998		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
999		"X-Msh-Os-Version":   runtime.GOOS,
1000		"X-Msh-Device-Id":    deviceID,
1001	}
1002}
1003
1004func envVarName(provider string) string {
1005	switch provider {
1006	case "openai", "openai-codex", "openai-responses":
1007		return "OPENAI"
1008	case "kimi":
1009		return "KIMI"
1010	case "deepseek":
1011		return "DEEPSEEK"
1012	case "google":
1013		return "GEMINI"
1014	case "ollama":
1015		return "OLLAMA"
1016	case "moonshotai", "moonshotai-cn":
1017		return "MOONSHOT"
1018	case "groq":
1019		return "GROQ"
1020	case "xai":
1021		return "XAI"
1022	case "cerebras":
1023		return "CEREBRAS"
1024	case "together":
1025		return "TOGETHER"
1026	case "huggingface":
1027		return "HF"
1028	case "openrouter":
1029		return "OPENROUTER"
1030	case "mistral":
1031		return "MISTRAL"
1032	case "zai":
1033		return "ZAI"
1034	case "xiaomi":
1035		return "XIAOMI"
1036	case "xiaomi-token-plan-ams":
1037		return "XIAOMI_TOKEN_PLAN_AMS"
1038	case "xiaomi-token-plan-cn":
1039		return "XIAOMI_TOKEN_PLAN_CN"
1040	case "xiaomi-token-plan-sgp":
1041		return "XIAOMI_TOKEN_PLAN_SGP"
1042	case "minimax":
1043		return "MINIMAX"
1044	case "minimax-cn":
1045		return "MINIMAX_CN"
1046	case "fireworks":
1047		return "FIREWORKS"
1048	case "vercel-ai-gateway":
1049		return "AI_GATEWAY"
1050	case "opencode", "opencode-go":
1051		return "OPENCODE"
1052	case "github-copilot":
1053		return "COPILOT_GITHUB_TOKEN"
1054	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1055		return "CLOUDFLARE"
1056	case "amazon-bedrock":
1057		return "AWS"
1058	case "google-vertex":
1059		return "GOOGLE_CLOUD"
1060	case "azure-openai-responses":
1061		return "AZURE_OPENAI"
1062	default:
1063		return "ANTHROPIC"
1064	}
1065}

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