v0.3.21 7fe8dbc

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

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