v0.3.30 2501d48

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close