v0.3.76 1866c03

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

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