v0.3.55 fdc6643

build.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close