v0.3.50 82191b3

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

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