v0.2.86 8fb72f3

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

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