v0.3.42 2ddba2d

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

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