v0.3.80 f3f644f

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

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