v0.2.23 c2c9a5e

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

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