v0.3.68 a98ca1a

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

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