v0.3.3 2d8ec25

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", provider.LlamaCPPProviderID:
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", provider.LlamaCPPProviderID,
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	"llama":        provider.LlamaCPPProviderID,
275	"llamacpp":     provider.LlamaCPPProviderID,
276}
277
278// canonicalProvider normalises a user-supplied provider name: trims
279// surrounding whitespace, lower-cases it, and resolves any known alias
280// to its canonical id. Unknown names are returned trimmed/lower-cased
281// and unchanged so the existing unknown-provider handling still runs.
282func canonicalProvider(name string) string {
283	n := strings.ToLower(strings.TrimSpace(name))
284	if n == "" {
285		return n
286	}
287	if canon, ok := providerAliases[n]; ok {
288		return canon
289	}
290	return n
291}
292
293// Resolve merges args, config, and env into a Resolved set.
294//
295// Unlike the earlier version, Resolve NEVER returns an error for
296// missing credentials: the TUI can start without them and launch a
297// login flow. requireCred controls whether missing credentials are a
298// hard error (used by print/json modes).
299func Resolve(args Args, requireCred bool) (Resolved, error) {
300	cfg, _ := LoadConfig()
301
302	// User-requested provider (explicit > config > default).
303	// Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
304	// before validation so an alias is never mistaken for an unknown
305	// provider and silently downgraded to anthropic.
306	argProvider := canonicalProvider(args.Provider)
307	provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
308	if !isKnownProvider(provName) {
309		// Unknown provider (maybe removed or renamed). Fall back to
310		// the first provider that has credentials, or anthropic.
311		// Custom providers (from models.json) are already accepted
312		// by isKnownProvider, so we only reach here for truly unknown
313		// names.
314		provName = "anthropic"
315		if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
316			provName = "openai"
317		}
318		if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil {
319			provName = "openai-codex"
320		}
321		if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
322			provName = "kimi"
323		}
324		if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil {
325			provName = "deepseek"
326		}
327		if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
328			provName = "anthropic"
329		}
330		// Reset the saved config so this doesn't keep happening.
331		cfg.Provider = provName
332		cfg.Model = ""
333		_ = SaveConfig(cfg)
334	}
335
336	var (
337		cred      string
338		method    string
339		accountID string
340		credErr   error
341	)
342	if provName == "ollama" {
343		cred = firstNonEmpty(args.APIKey, "ollama")
344		method = "apikey"
345	} else {
346		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
347	}
348
349	// Persist --api-key for custom providers so subsequent runs don't
350	// need to pass it again.
351	if !isBuiltinProvider(provName) && args.APIKey != "" {
352		if store := AuthStoreFor(); store != nil {
353			_ = store.SetAPIKey(provName, args.APIKey)
354		}
355	}
356
357	// If the user did NOT explicitly pick a provider (neither via --provider
358	// nor by saving one in config.json) and the default one has no
359	// credentials, auto-fall-back to whichever provider is actually logged
360	// in. That way running plain `zot` after `/login` (any provider) never
361	// shows a "not logged in" banner.
362	//
363	// Critical: when the user HAS saved a provider in config.json (e.g.
364	// "deepseek" with "deepseek-v4-flash"), that choice is respected even
365	// if no credential is found — the tui will show the login dialog or
366	// surface a clear credential error rather than silently switching to
367	// a completely different provider and model.
368	// autoFellBack tracks whether the provider was changed by the
369	// auto-fallback loop (no explicit provider from CLI or config,
370	// and the default had no credentials). When false, the provider
371	// came from the user's explicit choice (--provider or config.json)
372	// and must be respected, including any model id that may belong
373	// to a different provider in the catalog — for example when using
374	// a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash".
375	var autoFellBack bool
376	userPickedProvider := args.Provider != "" || cfg.Provider != ""
377	if credErr != nil && !userPickedProvider && provName != "ollama" {
378		autoFellBack = true
379		// Scan every known provider (not a hardcoded subset) so any
380		// env-based credential is discovered, e.g. an env-only
381		// amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
382		// IAM keys) when no config.json pins the provider, such as after
383		// pointing ZOT_HOME at a fresh home dir. Iteration order of
384		// knownProviders defines fallback priority. Local providers without a
385		// default model are skipped because selecting either one here would
386		// fail before the user can choose a model.
387		for _, other := range knownProviders {
388			if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {
389				continue
390			}
391			if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
392				provName = other
393				cred, method, accountID, credErr = c, m, a, err
394				break
395			}
396		}
397	}
398
399	model := firstNonEmpty(args.Model, cfg.Model)
400	if model == "" {
401		if provName == "ollama" || provName == provider.LlamaCPPProviderID {
402			return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
403		}
404		model = defaultModelForProvider(provName)
405	}
406	// If the resolved model belongs to a different provider (e.g. config
407	// says gpt-5 but we auto-fell back to anthropic), pick that provider's
408	// default. This override only fires when the provider was auto-fallback-
409	// changed, NOT when the user explicitly configured the pair — gateway/
410	// router providers (openrouter, vercel-ai-gateway, etc.) can serve
411	// models from any provider in the catalog.
412	if autoFellBack && provName != "ollama" {
413		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
414			model = defaultModelForProvider(provName)
415		}
416	}
417	resolvedModel, err := provider.FindModel(provName, model)
418	if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) {
419		// Local providers are intentionally open-catalogue: any model id the
420		// configured server understands is valid, even if it is not cached.
421		resolvedModel = provider.Model{
422			Provider:      provName,
423			ID:            model,
424			DisplayName:   model,
425			ContextWindow: 128000,
426			MaxOutput:     16384,
427			BaseURL:       args.BaseURL,
428			Source:        provName,
429		}
430		err = nil
431	}
432	// Custom providers are open-catalogue like ollama: any model id the
433	// endpoint understands is valid. Use the provider-level base URL.
434	if err != nil {
435		if cfg, ok := provider.CustomProviders()[provName]; ok {
436			resolvedModel = provider.Model{
437				Provider:    provName,
438				ID:          model,
439				DisplayName: model,
440				BaseURL:     cfg.BaseURL,
441				Source:      "user",
442			}
443			err = nil
444		}
445	}
446	if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
447		// Fall back to the provider-level base URL when the model does
448		// not define its own endpoint.
449		resolvedModel.BaseURL = cfg.BaseURL
450	}
451	if err != nil {
452		// The model the user (or persisted config) asked for is no
453		// longer in the active catalogue — they probably removed it
454		// from their models.json or upgraded zot and the id changed.
455		// Refusing to launch is the wrong move: it strands the user
456		// with no way to even open the TUI and pick a new model.
457		// Fall back to the provider's default, warn on stderr, and,
458		// when the stale id came from the persisted config (not an
459		// explicit --model flag), repair the config so the warning
460		// doesn't repeat on every launch.
461
462		// Gateway providers can accept route-qualified ids that are not in
463		// zot's local catalog yet, for example OpenRouter's
464		// "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
465		// plain unknown values are likely typos and should still fall back.
466		if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
467			resolvedModel = provider.Model{
468				Provider:      provName,
469				ID:            model,
470				DisplayName:   model,
471				ContextWindow: 1000000,
472				MaxOutput:     64000,
473				BaseURL:       "",
474				Source:        "gateway",
475			}
476			err = nil
477			// Don't repair config — the routed model id may be valid upstream.
478		} else {
479			fallback := defaultModelForProvider(provName)
480			fm, ferr := provider.FindModel(provName, fallback)
481			if ferr != nil {
482				// Even the provider default is gone (catastrophic
483				// catalogue trim). Last resort: any model on this
484				// provider, then the global DefaultModel.
485				if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
486					fm = candidates[0]
487					ferr = nil
488				} else {
489					fm = provider.DefaultModel
490					ferr = nil
491				}
492			}
493			fmt.Fprintf(os.Stderr,
494				"zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
495				model, fm.ID)
496			if args.Model == "" && cfg.Model == model {
497				cfg.Model = fm.ID
498				_ = SaveConfig(cfg)
499			}
500			resolvedModel = fm
501			model = fm.ID
502		}
503	}
504
505	explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
506
507	// If the model defines a base URL (e.g. local ollama) and the
508	// user didn't pass --base-url, use the model's URL. For ollama,
509	// keep http://localhost:11434 as a fallback only after the model
510	// metadata has had a chance to provide a custom baseUrl.
511	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
512		args.BaseURL = resolvedModel.BaseURL
513	}
514	if args.BaseURL == "" && provName == "ollama" {
515		args.BaseURL = "http://localhost:11434"
516	}
517	if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
518		managementURL, _, configErr := ResolveLlamaCPPConfig()
519		if configErr != nil {
520			return Resolved{}, configErr
521		}
522		var baseErr error
523		args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
524		if baseErr != nil {
525			return Resolved{}, baseErr
526		}
527	}
528
529	// Insecure TLS is intentionally scoped to explicit custom endpoints.
530	// Built-in provider base URLs, auth calls, and model discovery keep normal
531	// certificate verification even when --insecure is present.
532	insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
533
534	// If the model has a base URL, credentials are optional (local
535	// models like ollama don't need real API keys).
536	if resolvedModel.BaseURL != "" && credErr != nil {
537		cred = "ollama"
538		credErr = nil
539		requireCred = false
540	}
541
542	if credErr != nil && requireCred {
543		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
544			credErr, envVarName(provName))
545	}
546
547	sandbox := tools.NewSandbox(args.CWD)
548	if cfg.JailByDefault != nil && *cfg.JailByDefault {
549		sandbox.Lock()
550	}
551	if args.PermissionSet != nil {
552		sandbox.SetPermissions(args.PermissionSet)
553	}
554	reg := buildToolRegistry(args, args.CWD, sandbox)
555
556	docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
557
558	// Skill discovery: scan project + global locations + built-in
559	// skills shipped with the binary. If any are found, register
560	// the on-demand `skill` loader tool plus a system-prompt
561	// manifest so the model knows what's available.
562	//
563	// --no-skill bypasses the entire mechanism: no manifest in the
564	// system prompt, no `skill` tool in the registry. Useful for a
565	// clean-room run with zero extra context biasing the model.
566	var (
567		discovered    []*skills.Skill
568		skillTool     *skills.Tool
569		skillAddendum string
570	)
571	if !args.NoSkill {
572		homeDir, _ := os.UserHomeDir()
573		discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
574		if len(discovered) > 0 {
575			skillTool = skills.NewTool(discovered)
576			reg[skillTool.Name()] = skillTool
577			skillAddendum = skills.SystemPromptAddendum(discovered)
578		}
579	}
580	_ = skillTool
581
582	summaries := toolSummaries(reg, args)
583
584	append_ := append([]string(nil), args.AppendSystemPrompt...)
585	if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
586		append_ = append(append_, agentsAddendum)
587	}
588	if skillAddendum != "" {
589		append_ = append(append_, skillAddendum)
590	}
591	if AutoSwarmEnabled() {
592		append_ = append(append_, AutoSwarmSystemAddendum)
593	}
594
595	// Custom system prompt resolution order:
596	//   1. --system-prompt flag (highest priority; ad-hoc per run)
597	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
598	//   3. built-in default (defaultIdentity + defaultGuidelines)
599	custom := args.SystemPrompt
600	if custom == "" {
601		custom = readUserSystemPrompt(ZotHome())
602	}
603
604	sys := BuildSystemPrompt(SystemPromptOpts{
605		CWD:        args.CWD,
606		Tools:      summaries,
607		Custom:     custom,
608		Append:     append_,
609		ZotDocsDir: docsDir,
610	})
611
612	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
613	temperature := args.Temperature
614	if temperature == nil {
615		temperature = cfg.Temperature
616	}
617
618	max := args.MaxSteps // 0 = unlimited
619
620	return Resolved{
621		Provider:         provName,
622		Model:            model,
623		Credential:       cred,
624		AuthMethod:       method,
625		AccountID:        accountID,
626		BaseURL:          args.BaseURL,
627		InsecureTLS:      insecureTLS,
628		CWD:              args.CWD,
629		Reasoning:        reasoning,
630		Temperature:      temperature,
631		ToolRegistry:     reg,
632		ToolSummary:      summaries,
633		SystemPrompt:     sys,
634		MaxSteps:         max,
635		MaxOutput:        resolvedModel.MaxOutput,
636		Sandbox:          sandbox,
637		SkillTool:        skillTool,
638		systemAppend:     append_,
639		systemCustom:     custom,
640		toolDescriptions: descMapFromSummaries(summaries),
641	}, nil
642}
643
644// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
645// trimmed contents, or "" when the file is missing / unreadable /
646// empty. Errors are intentionally swallowed: the file is optional,
647// and any failure to read it should fall back to the built-in
648// default system prompt rather than crash the run.
649func readUserSystemPrompt(zotHome string) string {
650	if zotHome == "" {
651		return ""
652	}
653	path := filepath.Join(zotHome, "SYSTEM.md")
654	raw, err := os.ReadFile(path)
655	if err != nil {
656		return ""
657	}
658	return strings.TrimSpace(string(raw))
659}
660
661// readAgentsContext loads optional AGENTS.md instruction files. No
662// default file is created or required: zot only includes files that
663// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
664// followed by project instructions from the top-most parent down to cwd.
665func readAgentsContext(cwd, zotHome string) string {
666	type contextFile struct {
667		path    string
668		content string
669	}
670	var files []contextFile
671	seen := map[string]bool{}
672	add := func(path string) {
673		if path == "" {
674			return
675		}
676		abs, err := filepath.Abs(path)
677		if err == nil {
678			path = abs
679		}
680		if seen[path] {
681			return
682		}
683		raw, err := os.ReadFile(path)
684		if err != nil {
685			return
686		}
687		content := strings.TrimSpace(string(raw))
688		if content == "" {
689			return
690		}
691		seen[path] = true
692		files = append(files, contextFile{path: path, content: content})
693	}
694	addFirstFromDir := func(dir string) {
695		if dir == "" {
696			return
697		}
698		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
699			path := filepath.Join(dir, name)
700			if _, err := os.Stat(path); err == nil {
701				add(path)
702				return
703			}
704		}
705	}
706
707	addFirstFromDir(zotHome)
708
709	if cwd != "" {
710		abs, err := filepath.Abs(cwd)
711		if err == nil {
712			cwd = abs
713		}
714		var dirs []string
715		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
716			dirs = append(dirs, dir)
717			parent := filepath.Dir(dir)
718			if parent == dir {
719				break
720			}
721		}
722		for i := len(dirs) - 1; i >= 0; i-- {
723			addFirstFromDir(dirs[i])
724		}
725	}
726
727	if len(files) == 0 {
728		return ""
729	}
730	var sb strings.Builder
731	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")
732	for _, f := range files {
733		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
734	}
735	return strings.TrimSpace(sb.String())
736}
737
738// descMapFromSummaries indexes the human-readable descriptions for
739// the renderToolsSection rebuild path.
740func descMapFromSummaries(summaries []ToolSummary) map[string]string {
741	out := make(map[string]string, len(summaries))
742	for _, s := range summaries {
743		out[s.Name] = s.Description
744	}
745	return out
746}
747
748// NewClient returns a provider.Client for r, choosing the auth mode
749// based on r.AuthMethod. Panics if no credential is present; callers
750// must check HasCredential() first.
751func (r Resolved) NewClient() provider.Client {
752	if !r.HasCredential() {
753		panic("NewClient called without credential; check HasCredential first")
754	}
755	wrap := r.withHTTPClient
756	switch r.Provider {
757	case "ollama":
758		return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
759	case provider.LlamaCPPProviderID:
760		credential := r.Credential
761		if credential == "local" {
762			credential = ""
763		}
764		return wrap(provider.NewOpenAI(credential, r.BaseURL))
765	case "kimi":
766		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
767		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
768		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
769		if r.AuthMethod == "oauth" {
770			return r.wrapWithRefresh(inner)
771		}
772		return inner
773	case "moonshotai":
774		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
775	case "moonshotai-cn":
776		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
777	case "deepseek":
778		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
779	case "openai":
780		return provider.NewModelRouter("openai",
781			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
782			map[string]provider.Client{
783				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
784			})
785	case "openai-codex":
786		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
787		return r.wrapWithRefresh(inner)
788	case "openai-responses":
789		// Public OpenAI Responses API (api.openai.com/v1/responses) via
790		// API key. Separate provider from `openai` (Chat Completions) and
791		// from `openai-codex` (ChatGPT subscription OAuth).
792		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
793	case "google":
794		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
795	case "cerebras":
796		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
797	case "groq":
798		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
799	case "xai":
800		inner := r.newXAIClient(r.Credential)
801		if r.AuthMethod == "oauth" {
802			return r.wrapWithRefresh(inner)
803		}
804		return inner
805	case "together":
806		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
807	case "huggingface":
808		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
809	case "openrouter":
810		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
811	case "zai":
812		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
813	case "xiaomi":
814		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
815	case "xiaomi-token-plan-ams":
816		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
817	case "xiaomi-token-plan-cn":
818		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
819	case "xiaomi-token-plan-sgp":
820		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
821	case "opencode":
822		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
823	case "opencode-go":
824		return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
825	case "minimax":
826		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
827	case "minimax-cn":
828		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
829	case "fireworks":
830		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
831	case "vercel-ai-gateway":
832		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
833	case "mistral":
834		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
835	case "amazon-bedrock":
836		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
837	case "google-vertex":
838		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
839	case "azure-openai-responses":
840		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
841	case "github-copilot":
842		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
843	case "cloudflare-workers-ai":
844		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
845	case "cloudflare-ai-gateway":
846		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
847	default:
848		// Custom providers: choose wire format from the models.json api field.
849		if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
850			switch cfg.API {
851			case "anthropic":
852				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
853			default: // "openai"
854				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
855			}
856		}
857		if r.AuthMethod == "oauth" {
858			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
859			return r.wrapWithRefresh(inner)
860		}
861		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
862	}
863}
864
865func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
866	if !r.InsecureTLS {
867		return c
868	}
869	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
870}
871
872func (r Resolved) newXAIClient(token string) provider.Client {
873	return provider.NewModelRouter("xai",
874		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
875		map[string]provider.Client{
876			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
877		})
878}
879
880// wrapWithRefresh wraps an OAuth client so the access token is
881// refreshed automatically before each API call. Without this, long
882// sessions (hours) silently fail when the 1-hour token expires.
883func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
884	provName := r.Provider
885	tokenProvider := provName
886	if provName == "openai-codex" {
887		tokenProvider = "openai"
888	}
889	baseURL := r.BaseURL
890	accountID := r.AccountID
891
892	refreshFn := func(ctx context.Context) (string, error) {
893		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
894		if err != nil {
895			return "", err
896		}
897		return tok.AccessToken, nil
898	}
899
900	factory := func(token string) provider.Client {
901		switch provName {
902		case "openai-codex":
903			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
904		case "kimi":
905			// anthropic-messages on api.kimi.com/coding.
906			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
907		case "xai":
908			return r.newXAIClient(token)
909		default:
910			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
911		}
912	}
913
914	return provider.NewRefreshingClient(inner, refreshFn, factory)
915}
916
917// UseSandbox replaces the sandbox pointer that every tool in r's
918// registry references. Used to keep the /jail state stable across
919// agent rebuilds (e.g. /login, /model switching providers).
920func (r *Resolved) UseSandbox(s *tools.Sandbox) {
921	if s == nil || r == nil {
922		return
923	}
924	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
925		s.Permissions = r.Sandbox.Permissions
926	}
927	r.Sandbox = s
928	for name, t := range r.ToolRegistry {
929		switch v := t.(type) {
930		case *tools.ReadTool:
931			v.Sandbox = s
932		case *tools.WriteTool:
933			v.Sandbox = s
934		case *tools.EditTool:
935			v.Sandbox = s
936		case *tools.BashTool:
937			v.Sandbox = s
938		}
939		_ = name
940	}
941}
942
943// NewAgent constructs a core.Agent from r. Requires a credential.
944func (r Resolved) NewAgent() *core.Agent {
945	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
946	a.MaxSteps = r.MaxSteps
947	a.MaxTokens = r.MaxOutput
948	a.Reasoning = r.Reasoning
949	a.Temperature = r.Temperature
950	return a
951}
952
953func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
954	if args.NoTools {
955		return core.Registry{}
956	}
957	all := map[string]core.Tool{
958		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
959		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
960		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
961		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
962	}
963	reg := core.Registry{}
964	if len(args.Tools) == 0 {
965		for _, t := range all {
966			reg[t.Name()] = t
967		}
968		return reg
969	}
970	for _, name := range args.Tools {
971		if t, ok := all[name]; ok {
972			reg[name] = t
973		}
974	}
975	return reg
976}
977
978func toolSummaries(reg core.Registry, args Args) []ToolSummary {
979	order := []string{"read", "write", "edit", "bash"}
980	var out []ToolSummary
981	for _, name := range order {
982		if t, ok := reg[name]; ok {
983			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
984		}
985	}
986	return out
987}
988
989func firstNonEmpty(vals ...string) string {
990	for _, v := range vals {
991		if v != "" {
992			return v
993		}
994	}
995	return ""
996}
997
998func kimiCodeHeaders() map[string]string {
999	host, _ := os.Hostname()
1000	if host == "" {
1001		host = "unknown"
1002	}
1003	deviceID := ""
1004	if home, err := os.UserHomeDir(); err == nil {
1005		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1006			deviceID = strings.TrimSpace(string(b))
1007		}
1008	}
1009	if deviceID == "" {
1010		deviceID = "zot"
1011	}
1012	return map[string]string{
1013		"User-Agent":         "KimiCLI/1.41.0",
1014		"X-Msh-Platform":     "kimi_cli",
1015		"X-Msh-Version":      "1.41.0",
1016		"X-Msh-Device-Name":  host,
1017		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1018		"X-Msh-Os-Version":   runtime.GOOS,
1019		"X-Msh-Device-Id":    deviceID,
1020	}
1021}
1022
1023func envVarName(provider string) string {
1024	switch provider {
1025	case "openai", "openai-codex", "openai-responses":
1026		return "OPENAI"
1027	case "kimi":
1028		return "KIMI"
1029	case "deepseek":
1030		return "DEEPSEEK"
1031	case "google":
1032		return "GEMINI"
1033	case "ollama":
1034		return "OLLAMA"
1035	case "llama.cpp":
1036		return "LLAMA"
1037	case "moonshotai", "moonshotai-cn":
1038		return "MOONSHOT"
1039	case "groq":
1040		return "GROQ"
1041	case "xai":
1042		return "XAI"
1043	case "cerebras":
1044		return "CEREBRAS"
1045	case "together":
1046		return "TOGETHER"
1047	case "huggingface":
1048		return "HF"
1049	case "openrouter":
1050		return "OPENROUTER"
1051	case "mistral":
1052		return "MISTRAL"
1053	case "zai":
1054		return "ZAI"
1055	case "xiaomi":
1056		return "XIAOMI"
1057	case "xiaomi-token-plan-ams":
1058		return "XIAOMI_TOKEN_PLAN_AMS"
1059	case "xiaomi-token-plan-cn":
1060		return "XIAOMI_TOKEN_PLAN_CN"
1061	case "xiaomi-token-plan-sgp":
1062		return "XIAOMI_TOKEN_PLAN_SGP"
1063	case "minimax":
1064		return "MINIMAX"
1065	case "minimax-cn":
1066		return "MINIMAX_CN"
1067	case "fireworks":
1068		return "FIREWORKS"
1069	case "vercel-ai-gateway":
1070		return "AI_GATEWAY"
1071	case "opencode", "opencode-go":
1072		return "OPENCODE"
1073	case "github-copilot":
1074		return "COPILOT_GITHUB_TOKEN"
1075	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1076		return "CLOUDFLARE"
1077	case "amazon-bedrock":
1078		return "AWS"
1079	case "google-vertex":
1080		return "GOOGLE_CLOUD"
1081	case "azure-openai-responses":
1082		return "AZURE_OPENAI"
1083	default:
1084		return "ANTHROPIC"
1085	}
1086}

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