v0.2.33 94ece7d

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

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