v0.3.39 be11074

build.go RawHistoryFolder

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

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