v0.2.40 0250ce1

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 and the default one
352	// has no credentials, auto-fall-back to whichever provider is actually
353	// logged in. That way running plain `zot` after `/login` (any provider)
354	// never shows a "not logged in" banner.
355	userPickedProvider := args.Provider != ""
356	if credErr != nil && !userPickedProvider && provName != "ollama" {
357		// Scan every known provider (not a hardcoded subset) so any
358		// env-based credential is discovered, e.g. an env-only
359		// amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
360		// IAM keys) when no config.json pins the provider, such as after
361		// pointing ZOT_HOME at a fresh home dir. Iteration order of
362		// knownProviders defines fallback priority. ollama is skipped:
363		// it has no credential and would always "match".
364		for _, other := range knownProviders {
365			if other == provName || other == "ollama" {
366				continue
367			}
368			if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
369				provName = other
370				cred, method, accountID, credErr = c, m, a, err
371				break
372			}
373		}
374	}
375
376	model := firstNonEmpty(args.Model, cfg.Model)
377	if model == "" {
378		if provName == "ollama" {
379			return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
380		}
381		model = defaultModelForProvider(provName)
382	}
383	// If the resolved model belongs to a different provider (e.g. config
384	// says gpt-5 but we fell back to anthropic), pick that provider's default.
385	if provName != "ollama" {
386		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
387			model = defaultModelForProvider(provName)
388		}
389	}
390	resolvedModel, err := provider.FindModel(provName, model)
391	if err != nil && provName == "ollama" {
392		// ollama is intentionally open-catalogue: any model id the
393		// local server understands is valid, even if not in the
394		// baked-in catalog.
395		resolvedModel = provider.Model{
396			Provider:      "ollama",
397			ID:            model,
398			DisplayName:   model,
399			ContextWindow: 32768,
400			MaxOutput:     8192,
401			BaseURL:       args.BaseURL,
402			Source:        "ollama",
403		}
404		err = nil
405	}
406	// Custom providers are open-catalogue like ollama: any model id the
407	// endpoint understands is valid. Use the provider-level base URL.
408	if err != nil {
409		if cfg, ok := provider.CustomProviders()[provName]; ok {
410			resolvedModel = provider.Model{
411				Provider:    provName,
412				ID:          model,
413				DisplayName: model,
414				BaseURL:     cfg.BaseURL,
415				Source:      "user",
416			}
417			err = nil
418		}
419	} else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
420		// Fall back to the provider-level base URL when the model does
421		// not define its own endpoint.
422		resolvedModel.BaseURL = cfg.BaseURL
423	}
424	if err != nil {
425		// The model the user (or persisted config) asked for is no
426		// longer in the active catalogue — they probably removed it
427		// from their models.json or upgraded zot and the id changed.
428		// Refusing to launch is the wrong move: it strands the user
429		// with no way to even open the TUI and pick a new model.
430		// Fall back to the provider's default, warn on stderr, and,
431		// when the stale id came from the persisted config (not an
432		// explicit --model flag), repair the config so the warning
433		// doesn't repeat on every launch.
434		fallback := defaultModelForProvider(provName)
435		fm, ferr := provider.FindModel(provName, fallback)
436		if ferr != nil {
437			// Even the provider default is gone (catastrophic
438			// catalogue trim). Last resort: any model on this
439			// provider, then the global DefaultModel.
440			if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
441				fm = candidates[0]
442				ferr = nil
443			} else {
444				fm = provider.DefaultModel
445				ferr = nil
446			}
447		}
448		fmt.Fprintf(os.Stderr,
449			"zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
450			model, fm.ID)
451		if args.Model == "" && cfg.Model == model {
452			cfg.Model = fm.ID
453			_ = SaveConfig(cfg)
454		}
455		resolvedModel = fm
456		model = fm.ID
457	}
458
459	explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
460
461	// If the model defines a base URL (e.g. local ollama) and the
462	// user didn't pass --base-url, use the model's URL. For ollama,
463	// keep http://localhost:11434 as a fallback only after the model
464	// metadata has had a chance to provide a custom baseUrl.
465	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
466		args.BaseURL = resolvedModel.BaseURL
467	}
468	if args.BaseURL == "" && provName == "ollama" {
469		args.BaseURL = "http://localhost:11434"
470	}
471
472	// Insecure TLS is intentionally scoped to explicit custom endpoints.
473	// Built-in provider base URLs, auth calls, and model discovery keep normal
474	// certificate verification even when --insecure is present.
475	insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
476
477	// If the model has a base URL, credentials are optional (local
478	// models like ollama don't need real API keys).
479	if resolvedModel.BaseURL != "" && credErr != nil {
480		cred = "ollama"
481		credErr = nil
482		requireCred = false
483	}
484
485	if credErr != nil && requireCred {
486		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
487			credErr, envVarName(provName))
488	}
489
490	sandbox := tools.NewSandbox(args.CWD)
491	reg := buildToolRegistry(args, args.CWD, sandbox)
492
493	docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
494
495	// Skill discovery: scan project + global locations + built-in
496	// skills shipped with the binary. If any are found, register
497	// the on-demand `skill` loader tool plus a system-prompt
498	// manifest so the model knows what's available.
499	//
500	// --no-skill bypasses the entire mechanism: no manifest in the
501	// system prompt, no `skill` tool in the registry. Useful for a
502	// clean-room run with zero extra context biasing the model.
503	var (
504		discovered    []*skills.Skill
505		skillTool     *skills.Tool
506		skillAddendum string
507	)
508	if !args.NoSkill {
509		homeDir, _ := os.UserHomeDir()
510		discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
511		if len(discovered) > 0 {
512			skillTool = skills.NewTool(discovered)
513			reg[skillTool.Name()] = skillTool
514			skillAddendum = skills.SystemPromptAddendum(discovered)
515		}
516	}
517	_ = skillTool
518
519	summaries := toolSummaries(reg, args)
520
521	append_ := append([]string(nil), args.AppendSystemPrompt...)
522	if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
523		append_ = append(append_, agentsAddendum)
524	}
525	if skillAddendum != "" {
526		append_ = append(append_, skillAddendum)
527	}
528	if AutoSwarmEnabled() {
529		append_ = append(append_, AutoSwarmSystemAddendum)
530	}
531
532	// Custom system prompt resolution order:
533	//   1. --system-prompt flag (highest priority; ad-hoc per run)
534	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
535	//   3. built-in default (defaultIdentity + defaultGuidelines)
536	custom := args.SystemPrompt
537	if custom == "" {
538		custom = readUserSystemPrompt(ZotHome())
539	}
540
541	sys := BuildSystemPrompt(SystemPromptOpts{
542		CWD:        args.CWD,
543		Tools:      summaries,
544		Custom:     custom,
545		Append:     append_,
546		ZotDocsDir: docsDir,
547	})
548
549	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
550	temperature := args.Temperature
551	if temperature == nil {
552		temperature = cfg.Temperature
553	}
554
555	max := args.MaxSteps // 0 = unlimited
556
557	return Resolved{
558		Provider:         provName,
559		Model:            model,
560		Credential:       cred,
561		AuthMethod:       method,
562		AccountID:        accountID,
563		BaseURL:          args.BaseURL,
564		InsecureTLS:      insecureTLS,
565		CWD:              args.CWD,
566		Reasoning:        reasoning,
567		Temperature:      temperature,
568		ToolRegistry:     reg,
569		ToolSummary:      summaries,
570		SystemPrompt:     sys,
571		MaxSteps:         max,
572		MaxOutput:        resolvedModel.MaxOutput,
573		Sandbox:          sandbox,
574		SkillTool:        skillTool,
575		systemAppend:     append_,
576		systemCustom:     custom,
577		toolDescriptions: descMapFromSummaries(summaries),
578	}, nil
579}
580
581// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
582// trimmed contents, or "" when the file is missing / unreadable /
583// empty. Errors are intentionally swallowed: the file is optional,
584// and any failure to read it should fall back to the built-in
585// default system prompt rather than crash the run.
586func readUserSystemPrompt(zotHome string) string {
587	if zotHome == "" {
588		return ""
589	}
590	path := filepath.Join(zotHome, "SYSTEM.md")
591	raw, err := os.ReadFile(path)
592	if err != nil {
593		return ""
594	}
595	return strings.TrimSpace(string(raw))
596}
597
598// readAgentsContext loads optional AGENTS.md instruction files. No
599// default file is created or required: zot only includes files that
600// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
601// followed by project instructions from the top-most parent down to cwd.
602func readAgentsContext(cwd, zotHome string) string {
603	type contextFile struct {
604		path    string
605		content string
606	}
607	var files []contextFile
608	seen := map[string]bool{}
609	add := func(path string) {
610		if path == "" {
611			return
612		}
613		abs, err := filepath.Abs(path)
614		if err == nil {
615			path = abs
616		}
617		if seen[path] {
618			return
619		}
620		raw, err := os.ReadFile(path)
621		if err != nil {
622			return
623		}
624		content := strings.TrimSpace(string(raw))
625		if content == "" {
626			return
627		}
628		seen[path] = true
629		files = append(files, contextFile{path: path, content: content})
630	}
631	addFirstFromDir := func(dir string) {
632		if dir == "" {
633			return
634		}
635		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
636			path := filepath.Join(dir, name)
637			if _, err := os.Stat(path); err == nil {
638				add(path)
639				return
640			}
641		}
642	}
643
644	addFirstFromDir(zotHome)
645
646	if cwd != "" {
647		abs, err := filepath.Abs(cwd)
648		if err == nil {
649			cwd = abs
650		}
651		var dirs []string
652		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
653			dirs = append(dirs, dir)
654			parent := filepath.Dir(dir)
655			if parent == dir {
656				break
657			}
658		}
659		for i := len(dirs) - 1; i >= 0; i-- {
660			addFirstFromDir(dirs[i])
661		}
662	}
663
664	if len(files) == 0 {
665		return ""
666	}
667	var sb strings.Builder
668	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")
669	for _, f := range files {
670		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
671	}
672	return strings.TrimSpace(sb.String())
673}
674
675// descMapFromSummaries indexes the human-readable descriptions for
676// the renderToolsSection rebuild path.
677func descMapFromSummaries(summaries []ToolSummary) map[string]string {
678	out := make(map[string]string, len(summaries))
679	for _, s := range summaries {
680		out[s.Name] = s.Description
681	}
682	return out
683}
684
685// NewClient returns a provider.Client for r, choosing the auth mode
686// based on r.AuthMethod. Panics if no credential is present; callers
687// must check HasCredential() first.
688func (r Resolved) NewClient() provider.Client {
689	if !r.HasCredential() {
690		panic("NewClient called without credential; check HasCredential first")
691	}
692	wrap := r.withHTTPClient
693	switch r.Provider {
694	case "ollama":
695		return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
696	case "kimi":
697		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
698		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
699		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
700		if r.AuthMethod == "oauth" {
701			return r.wrapWithRefresh(inner)
702		}
703		return inner
704	case "moonshotai":
705		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
706	case "moonshotai-cn":
707		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
708	case "deepseek":
709		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
710	case "openai":
711		return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
712	case "openai-codex":
713		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
714		return r.wrapWithRefresh(inner)
715	case "openai-responses":
716		// Public OpenAI Responses API (api.openai.com/v1/responses) via
717		// API key. Separate provider from `openai` (Chat Completions) and
718		// from `openai-codex` (ChatGPT subscription OAuth).
719		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
720	case "google":
721		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
722	case "cerebras":
723		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
724	case "groq":
725		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
726	case "xai":
727		return wrap(provider.NewXAI(r.Credential, r.BaseURL))
728	case "together":
729		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
730	case "huggingface":
731		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
732	case "openrouter":
733		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
734	case "zai":
735		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
736	case "xiaomi":
737		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
738	case "xiaomi-token-plan-ams":
739		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
740	case "xiaomi-token-plan-cn":
741		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
742	case "xiaomi-token-plan-sgp":
743		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
744	case "opencode":
745		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
746	case "opencode-go":
747		return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
748	case "minimax":
749		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
750	case "minimax-cn":
751		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
752	case "fireworks":
753		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
754	case "vercel-ai-gateway":
755		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
756	case "mistral":
757		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
758	case "amazon-bedrock":
759		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
760	case "google-vertex":
761		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
762	case "azure-openai-responses":
763		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
764	case "github-copilot":
765		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
766	case "cloudflare-workers-ai":
767		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
768	case "cloudflare-ai-gateway":
769		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
770	default:
771		// Custom providers: choose wire format from the models.json api field.
772		if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
773			switch cfg.API {
774			case "anthropic":
775				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
776			default: // "openai"
777				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
778			}
779		}
780		if r.AuthMethod == "oauth" {
781			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
782			return r.wrapWithRefresh(inner)
783		}
784		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
785	}
786}
787
788func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
789	if !r.InsecureTLS {
790		return c
791	}
792	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
793}
794
795// wrapWithRefresh wraps an OAuth client so the access token is
796// refreshed automatically before each API call. Without this, long
797// sessions (hours) silently fail when the 1-hour token expires.
798func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
799	provName := r.Provider
800	tokenProvider := provName
801	if provName == "openai-codex" {
802		tokenProvider = "openai"
803	}
804	baseURL := r.BaseURL
805	accountID := r.AccountID
806
807	refreshFn := func(ctx context.Context) (string, error) {
808		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
809		if err != nil {
810			return "", err
811		}
812		return tok.AccessToken, nil
813	}
814
815	factory := func(token string) provider.Client {
816		switch provName {
817		case "openai-codex":
818			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
819		case "kimi":
820			// anthropic-messages on api.kimi.com/coding.
821			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
822		default:
823			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
824		}
825	}
826
827	return provider.NewRefreshingClient(inner, refreshFn, factory)
828}
829
830// UseSandbox replaces the sandbox pointer that every tool in r's
831// registry references. Used to keep the /jail state stable across
832// agent rebuilds (e.g. /login, /model switching providers).
833func (r *Resolved) UseSandbox(s *tools.Sandbox) {
834	if s == nil || r == nil {
835		return
836	}
837	r.Sandbox = s
838	for name, t := range r.ToolRegistry {
839		switch v := t.(type) {
840		case *tools.ReadTool:
841			v.Sandbox = s
842		case *tools.WriteTool:
843			v.Sandbox = s
844		case *tools.EditTool:
845			v.Sandbox = s
846		case *tools.BashTool:
847			v.Sandbox = s
848		}
849		_ = name
850	}
851}
852
853// NewAgent constructs a core.Agent from r. Requires a credential.
854func (r Resolved) NewAgent() *core.Agent {
855	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
856	a.MaxSteps = r.MaxSteps
857	a.MaxTokens = r.MaxOutput
858	a.Reasoning = r.Reasoning
859	a.Temperature = r.Temperature
860	return a
861}
862
863func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
864	if args.NoTools {
865		return core.Registry{}
866	}
867	all := map[string]core.Tool{
868		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
869		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
870		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
871		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
872	}
873	reg := core.Registry{}
874	if len(args.Tools) == 0 {
875		for _, t := range all {
876			reg[t.Name()] = t
877		}
878		return reg
879	}
880	for _, name := range args.Tools {
881		if t, ok := all[name]; ok {
882			reg[name] = t
883		}
884	}
885	return reg
886}
887
888func toolSummaries(reg core.Registry, args Args) []ToolSummary {
889	order := []string{"read", "write", "edit", "bash"}
890	var out []ToolSummary
891	for _, name := range order {
892		if t, ok := reg[name]; ok {
893			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
894		}
895	}
896	return out
897}
898
899func firstNonEmpty(vals ...string) string {
900	for _, v := range vals {
901		if v != "" {
902			return v
903		}
904	}
905	return ""
906}
907
908func kimiCodeHeaders() map[string]string {
909	host, _ := os.Hostname()
910	if host == "" {
911		host = "unknown"
912	}
913	deviceID := ""
914	if home, err := os.UserHomeDir(); err == nil {
915		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
916			deviceID = strings.TrimSpace(string(b))
917		}
918	}
919	if deviceID == "" {
920		deviceID = "zot"
921	}
922	return map[string]string{
923		"User-Agent":         "KimiCLI/1.41.0",
924		"X-Msh-Platform":     "kimi_cli",
925		"X-Msh-Version":      "1.41.0",
926		"X-Msh-Device-Name":  host,
927		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
928		"X-Msh-Os-Version":   runtime.GOOS,
929		"X-Msh-Device-Id":    deviceID,
930	}
931}
932
933func envVarName(provider string) string {
934	switch provider {
935	case "openai", "openai-codex", "openai-responses":
936		return "OPENAI"
937	case "kimi":
938		return "KIMI"
939	case "deepseek":
940		return "DEEPSEEK"
941	case "google":
942		return "GEMINI"
943	case "ollama":
944		return "OLLAMA"
945	case "moonshotai", "moonshotai-cn":
946		return "MOONSHOT"
947	case "groq":
948		return "GROQ"
949	case "xai":
950		return "XAI"
951	case "cerebras":
952		return "CEREBRAS"
953	case "together":
954		return "TOGETHER"
955	case "huggingface":
956		return "HF"
957	case "openrouter":
958		return "OPENROUTER"
959	case "mistral":
960		return "MISTRAL"
961	case "zai":
962		return "ZAI"
963	case "xiaomi":
964		return "XIAOMI"
965	case "xiaomi-token-plan-ams":
966		return "XIAOMI_TOKEN_PLAN_AMS"
967	case "xiaomi-token-plan-cn":
968		return "XIAOMI_TOKEN_PLAN_CN"
969	case "xiaomi-token-plan-sgp":
970		return "XIAOMI_TOKEN_PLAN_SGP"
971	case "minimax":
972		return "MINIMAX"
973	case "minimax-cn":
974		return "MINIMAX_CN"
975	case "fireworks":
976		return "FIREWORKS"
977	case "vercel-ai-gateway":
978		return "AI_GATEWAY"
979	case "opencode", "opencode-go":
980		return "OPENCODE"
981	case "github-copilot":
982		return "COPILOT_GITHUB_TOKEN"
983	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
984		return "CLOUDFLARE"
985	case "amazon-bedrock":
986		return "AWS"
987	case "google-vertex":
988		return "GOOGLE_CLOUD"
989	case "azure-openai-responses":
990		return "AZURE_OPENAI"
991	default:
992		return "ANTHROPIC"
993	}
994}

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