v0.45.0 cb619f7

build.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"runtime"
9	"sort"
10	"strings"
11
12	zutdocs "github.com/bnema/zut"
13	"github.com/bnema/zut/packages/agent/lsp"
14	"github.com/bnema/zut/packages/agent/skills"
15	"github.com/bnema/zut/packages/agent/subagents"
16	"github.com/bnema/zut/packages/agent/tools"
17	"github.com/bnema/zut/packages/core"
18	"github.com/bnema/zut/packages/provider"
19)
20
21// ContextFile is an instruction file loaded into the system prompt.
22type ContextFile struct {
23	Path    string
24	Content string
25}
26
27// Resolved is the effective configuration after merging CLI, config, defaults.
28type Resolved struct {
29	Provider        string
30	Model           string
31	Credential      string // api key or oauth access token
32	AuthMethod      string // "apikey" | "oauth" | "" (no credential yet)
33	AccountID       string // ChatGPT account id (for openai oauth), "" otherwise
34	BaseURL         string
35	InsecureTLS     bool
36	CWD             string
37	Reasoning       string
38	Temperature     *float32
39	FastMode        bool
40	WebSearchPolicy subagents.WebSearchPolicy
41
42	ToolRegistry           core.Registry
43	ToolSummary            []ToolSummary
44	SystemPrompt           string
45	MaxSteps               int
46	DisableRepetitionGuard bool
47	Sandbox                *tools.Sandbox
48
49	// ContextWindow and MaxOutput retain the effective model metadata,
50	// including metadata synthesized for valid open-catalog local and routed
51	// models that provider.FindModel cannot rediscover later.
52	ContextWindow int
53
54	// MaxOutput is the resolved model's maximum output-token budget
55	// (from the catalog). Passed to the agent so each turn requests
56	// the model's full output capacity instead of the provider's
57	// conservative default (e.g. Bedrock's 4096, which truncates
58	// long writes/edits with stopReason=length).
59	MaxOutput int
60
61	// modelCatalog is a runtime-owned snapshot for providers whose model
62	// metadata is account- or endpoint-scoped. It prevents a later SDK
63	// runtime from changing request shaping for an already-created runtime.
64	modelCatalog              []provider.Model
65	modelCatalogAuthoritative bool
66
67	// customProviderConfig snapshots user-provider transport settings so
68	// client construction does not reread a mutable global registry.
69	customProviderConfig *provider.CustomProviderConfig
70
71	// SkillTool is the on-demand skill loader registered with the
72	// agent's tool registry, or nil if no SKILL.md files were
73	// discovered. Exposed so the tui can list / preview skills.
74	SkillTool *skills.Tool
75
76	// ContextFiles records the AGENTS.md files appended to SystemPrompt,
77	// in effective load order. Interactive mode uses this metadata to make
78	// otherwise invisible startup context inspectable without adding fake
79	// messages to the provider transcript.
80	ContextFiles []ContextFile
81
82	// skillsEnabled is true when discovery ran, even when it found no
83	// skills. It is false only for an explicit clean-room disable.
84	skillsEnabled bool
85
86	// Bookkeeping for MergeExtensionTools. Captured at Resolve time
87	// so the system prompt can be rebuilt later without re-running
88	// resolve.
89	systemAppend     []string
90	systemCustom     string
91	skillAddendum    string
92	toolDescriptions map[string]string
93}
94
95// HasCredential reports whether a credential was resolved.
96func (r Resolved) HasCredential() bool { return r.Credential != "" }
97
98// ModelCatalogSnapshot returns the runtime-owned model metadata captured while
99// resolving. It is used by SDK clients whose provider catalog is scoped to a
100// credential or endpoint.
101func (r Resolved) ModelCatalogSnapshot() []provider.Model {
102	return append([]provider.Model(nil), r.modelCatalog...)
103}
104
105// ModelCatalogIsAuthoritative reports whether the runtime snapshot came from
106// a successful provider catalog that is authoritative for model IDs.
107func (r Resolved) ModelCatalogIsAuthoritative() bool {
108	return r.modelCatalogAuthoritative
109}
110
111// MergeExtensionTools folds every tool registered by an extension
112// into r's ToolRegistry and re-renders the system prompt's tool
113// summary so the model sees both built-in and extension tools.
114//
115// Idempotent: calling twice with the same manager state has no
116// effect on the second pass (existing names are preserved). Built-in
117// tools always win on conflict.
118func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
119	if mgr == nil {
120		return
121	}
122	changed := false
123	for _, info := range mgr.Tools() {
124		// Web capability and other native names remain reserved even when their
125		// policy excludes the current session. An extension must not turn a
126		// normal CLI opt-out into a differently implemented capability.
127		if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "glob" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.PlanToolName {
128			continue
129		}
130		if _, exists := r.ToolRegistry[info.Name]; exists {
131			continue
132		}
133		r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
134		changed = true
135	}
136
137	// A nil SkillTool can mean discovery ran and found nothing, so use the
138	// explicit disable flag rather than the discovery result as the gate.
139	if source, ok := mgr.(ExtensionSkillSource); ok && r.skillsEnabled {
140		if bundled := source.Skills(); len(bundled) > 0 {
141			merged := mergeExtensionSkills(r.SkillTool, bundled)
142			if r.SkillTool == nil {
143				r.SkillTool = skills.NewTool(merged)
144				r.ToolRegistry[r.SkillTool.Name()] = r.SkillTool
145			} else {
146				r.SkillTool.SetSkills(merged)
147			}
148			append_ := append([]string(nil), r.systemAppend...)
149			if r.skillAddendum != "" {
150				filtered := append_[:0]
151				for _, item := range append_ {
152					if item != r.skillAddendum {
153						filtered = append(filtered, item)
154					}
155				}
156				append_ = filtered
157			}
158			r.skillAddendum = skills.SystemPromptAddendum(merged)
159			if r.skillAddendum != "" {
160				append_ = append(append_, r.skillAddendum)
161			}
162			r.systemAppend = append_
163			changed = true
164		}
165	}
166	if !changed {
167		return
168	}
169	// Re-render the system prompt with the merged tool and skill lists.
170	r.ToolSummary = toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions)
171	r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
172		CWD:        r.CWD,
173		Tools:      r.ToolSummary,
174		Custom:     r.systemCustom,
175		Append:     r.systemAppend,
176		ZutDocsDir: filepath.Join(ZutHome(), "docs"),
177	})
178}
179
180func mergeExtensionSkills(tool *skills.Tool, bundled []*skills.Skill) []*skills.Skill {
181	var existing []*skills.Skill
182	if tool != nil {
183		existing = tool.Skills()
184	}
185	merged := make([]*skills.Skill, 0, len(existing)+len(bundled))
186	seen := map[string]bool{}
187	add := func(list []*skills.Skill) {
188		for _, skill := range list {
189			if skill == nil || skill.Name == "" || seen[skill.Name] {
190				continue
191			}
192			seen[skill.Name] = true
193			merged = append(merged, skill)
194		}
195	}
196	var user, builtins []*skills.Skill
197	for _, skill := range existing {
198		if skill == nil || strings.HasPrefix(skill.Source, "extension ") {
199			continue
200		}
201		if skill.Builtin {
202			builtins = append(builtins, skill)
203		} else {
204			user = append(user, skill)
205		}
206	}
207	add(user)
208	add(bundled)
209	add(builtins)
210	return merged
211}
212
213// ExtensionToolSource is the slice of the extension manager that
214// MergeExtensionTools needs. Lives here as an interface so the
215// build package doesn't import packages/agent/extensions (which
216// imports core, which imports... avoid the cycle).
217type ExtensionToolSource interface {
218	Tools() []ExtensionToolInfo
219	NewExtensionTool(info ExtensionToolInfo) core.Tool
220}
221
222// ExtensionSkillSource exposes the optional bundled-skill surface without
223// forcing older ExtensionToolSource implementations to add it.
224type ExtensionSkillSource interface {
225	Skills() []*skills.Skill
226}
227
228// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
229// ExtensionToolSource here without importing the extensions
230// package. The cli wires a tiny adapter to bridge them.
231type ExtensionToolInfo struct {
232	Extension   string
233	Name        string
234	Description string
235	Schema      []byte
236	Deferred    bool
237}
238
239// toolSummariesFromRegistry rebuilds the system-prompt tool list
240// from a (possibly extended) registry, using cached descriptions for
241// the human-readable summary text.
242func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
243	out := make([]ToolSummary, 0, len(reg))
244	seen := make(map[string]bool, len(nativeToolSummaryOrder))
245	appendSummary := func(name string) {
246		t, ok := reg[name]
247		if !ok {
248			return
249		}
250		seen[name] = true
251		if d, ok := t.(interface{ Deferred() bool }); ok && d.Deferred() {
252			return
253		}
254		desc := t.Description()
255		if d, ok := cached[name]; ok && d != "" {
256			desc = d
257		}
258		out = append(out, ToolSummary{Name: name, Description: desc})
259	}
260	for _, name := range nativeToolSummaryOrder {
261		appendSummary(name)
262	}
263	remaining := make([]string, 0, len(reg)-len(seen))
264	for name := range reg {
265		if !seen[name] {
266			remaining = append(remaining, name)
267		}
268	}
269	sort.Strings(remaining)
270	for _, name := range remaining {
271		appendSummary(name)
272	}
273	return out
274}
275
276const openCodeGoBootstrapModel = "kimi-k2.6"
277
278// defaultModelForProvider returns the model id zut prefers when the
279// caller didn't pick one. Mirrors the per-provider switch used at
280// multiple points in Resolve; centralised so the unknown-model
281// recovery path and the no-model-configured path can't drift.
282//
283// Returns the empty string for "ollama", which has no built-in
284// default — the caller is expected to special-case ollama and
285// error or use whatever the user passed.
286func defaultModelForProvider(prov string) string {
287	switch prov {
288	case provider.ProviderOpenAI:
289		return "gpt-5"
290	case provider.ProviderOpenAICodex:
291		return "gpt-5.5"
292	case provider.ProviderOpenAIResponses:
293		return "gpt-5"
294	case provider.ProviderKimi:
295		return "kimi-for-coding"
296	case provider.ProviderDeepSeek:
297		return "deepseek-v4-pro"
298	case provider.ProviderGoogle:
299		return "gemini-2.5-pro"
300	case provider.ProviderOllama, provider.ProviderLlamaCPP:
301		return ""
302	case provider.ProviderMoonshot, provider.ProviderMoonshotCN:
303		return "kimi-k2.6"
304	case provider.ProviderCerebras:
305		return "qwen-3-235b-a22b-instruct-2507"
306	case provider.ProviderGroq:
307		return "llama-3.3-70b-versatile"
308	case provider.ProviderXAI:
309		return "grok-4.5"
310	case provider.ProviderTogether:
311		return "Qwen/Qwen3-Coder-480B-A35B-Instruct"
312	case provider.ProviderHuggingFace:
313		return "moonshotai/Kimi-K2-Instruct"
314	case provider.ProviderOpenRouter:
315		return "anthropic/claude-sonnet-4.5"
316	case provider.ProviderMistral:
317		return "mistral-large-latest"
318	case provider.ProviderZAI:
319		return "glm-4.7"
320	case provider.ProviderXiaomi, provider.ProviderXiaomiTokenPlanAMS, provider.ProviderXiaomiTokenPlanCN, provider.ProviderXiaomiTokenPlanSGP:
321		return "mimo-v2.5"
322	case provider.ProviderMiniMax, provider.ProviderMiniMaxCN:
323		return "MiniMax-M2.7"
324	case provider.ProviderFireworks:
325		return "accounts/fireworks/models/kimi-k2p6"
326	case provider.ProviderVercelAIGateway:
327		return "anthropic/claude-sonnet-4.5"
328	case provider.ProviderOpenCode:
329		return "claude-sonnet-4-5"
330	case provider.ProviderOpenCodeGo:
331		// OpenCode Go is synchronized at runtime, so prefer the first
332		// currently served model and keep the former default only as a
333		// bootstrap while the first refresh is still in flight.
334		if models := provider.ModelsForProvider(prov); len(models) > 0 {
335			return models[0].ID
336		}
337		return openCodeGoBootstrapModel
338	case provider.ProviderAmazonBedrock:
339		return "anthropic.claude-sonnet-4-5-20250929-v1:0"
340	case provider.ProviderGoogleVertex:
341		return "gemini-2.5-pro"
342	case provider.ProviderAzureOpenAIResponses:
343		return "gpt-5"
344	case provider.ProviderGitHubCopilot:
345		return "claude-sonnet-4.5"
346	default:
347		// Custom providers: pick the first model from the catalog for
348		// that provider, or fall back to the global default.
349		if models := provider.ModelsForProvider(prov); len(models) > 0 {
350			return models[0].ID
351		}
352		return provider.DefaultModel.ID
353	}
354}
355
356// knownProviders is the set of provider ids zut recognises. Used by
357// Resolve to validate args.Provider, by extension-callers, and by the
358// auto-fallback logic that picks any logged-in provider when the user's
359// preferred one has no credentials.
360var knownProviders = provider.BuiltinProviderIDs()
361
362func isKnownProvider(name string) bool {
363	for _, p := range knownProviders {
364		if p == name {
365			return true
366		}
367	}
368	_, ok := provider.CustomProviders()[name]
369	return ok
370}
371
372// isBuiltinProvider reports whether name is in the hardcoded
373// knownProviders list (not a user-defined custom provider).
374func isBuiltinProvider(name string) bool {
375	for _, p := range knownProviders {
376		if p == name {
377			return true
378		}
379	}
380	return false
381}
382
383// providerAliases maps common short / alternate provider names to the
384// canonical id in knownProviders. Users (and other agents) reach for
385// "bedrock" or "vertex" far more naturally than the fully-qualified
386// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
387// treated as an unknown provider and Resolve silently falls back to
388// anthropic, producing a misleading "no credential for anthropic" error
389// after the user explicitly picked, say, bedrock.
390var providerAliases = map[string]string{
391	"bedrock":      "amazon-bedrock",
392	"aws-bedrock":  "amazon-bedrock",
393	"amazon":       "amazon-bedrock",
394	"vertex":       "google-vertex",
395	"gcp-vertex":   "google-vertex",
396	"gemini":       "google",
397	"googleai":     "google",
398	"google-ai":    "google",
399	"azure":        "azure-openai-responses",
400	"azure-openai": "azure-openai-responses",
401	"copilot":      "github-copilot",
402	"github":       "github-copilot",
403	"codex":        "openai-codex",
404	"moonshot":     "moonshotai",
405	"kimi-code":    "kimi",
406	"ai-gateway":   "vercel-ai-gateway",
407	"vercel":       "vercel-ai-gateway",
408	"cloudflare":   "cloudflare-workers-ai",
409	"workers-ai":   "cloudflare-workers-ai",
410	"hf":           "huggingface",
411	"llama":        provider.LlamaCPPProviderID,
412	"llamacpp":     provider.LlamaCPPProviderID,
413}
414
415// canonicalProvider normalises a user-supplied provider name: trims
416// surrounding whitespace, lower-cases it, and resolves any known alias
417// to its canonical id. Unknown names are returned trimmed/lower-cased
418// and unchanged so the existing unknown-provider handling still runs.
419func canonicalProvider(name string) string {
420	n := strings.ToLower(strings.TrimSpace(name))
421	if n == "" {
422		return n
423	}
424	if canon, ok := providerAliases[n]; ok {
425		return canon
426	}
427	return n
428}
429
430func findSubagentProfile(cwd, name string) (*subagents.Profile, error) {
431	name = strings.TrimSpace(name)
432	if name == "" {
433		return nil, nil
434	}
435	home, _ := os.UserHomeDir()
436	profiles, discoveryErrors := subagents.Discover(cwd, home)
437	if len(discoveryErrors) > 0 {
438		return nil, fmt.Errorf("discover subagent profiles: %d profile discovery error(s)", len(discoveryErrors))
439	}
440	profile := subagents.Find(profiles, name)
441	if profile == nil {
442		return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and configured ZUT_AGENT_PROFILES paths)", name)
443	}
444	return profile, nil
445}
446
447// resolveWebSearchPolicy applies capability precedence at the owning resolver
448// boundary. Permission, tool-list, and named-profile restrictions are ceilings
449// on every caller-provided policy.
450func resolveWebSearchPolicy(args Args, cfg Config, cfgErr error) subagents.WebSearchPolicy {
451	if args.NoTools || args.PermissionSet != nil {
452		return subagents.WebSearchDeny
453	}
454	if args.ToolsSet && !toolListContains(args.Tools, "web_search") {
455		return subagents.WebSearchDeny
456	}
457
458	switch args.WebSearchPolicy {
459	case subagents.WebSearchDeny:
460		return subagents.WebSearchDeny
461	case subagents.WebSearchAllow:
462		return subagents.WebSearchAllow
463	case subagents.WebSearchInherit:
464		// Continue through normal-mode defaults below. Inherit is not a valid
465		// propagated worker decision.
466	default:
467		return subagents.WebSearchDeny
468	}
469	if args.ToolsSet {
470		return subagents.WebSearchAllow
471	}
472	if cfgErr != nil {
473		return subagents.WebSearchDeny
474	}
475	if cfg.WebSearchEnabledForCLI() {
476		return subagents.WebSearchAllow
477	}
478	return subagents.WebSearchDeny
479}
480
481func toolListContains(names []string, target string) bool {
482	for _, name := range names {
483		if name == target {
484			return true
485		}
486	}
487	return false
488}
489
490// webSearchAllowedForRegistry is kept separate from the resolver so direct
491// registry tests and callers that build a registry without persisted config
492// still get the normal default-on behavior.
493func webSearchAllowedForRegistry(args Args) bool {
494	if args.NoTools || args.PermissionSet != nil {
495		return false
496	}
497	if args.ToolsSet && !toolListContains(args.Tools, "web_search") {
498		return false
499	}
500	switch args.WebSearchPolicy {
501	case subagents.WebSearchAllow:
502		return true
503	case subagents.WebSearchDeny:
504		return false
505	default:
506		return true
507	}
508}
509
510func findModelForResolve(providerName, modelID string, scopedCatalog []provider.Model) (provider.Model, error) {
511	if providerName == provider.ProviderOpenCodeGo && scopedCatalog != nil {
512		for _, model := range scopedCatalog {
513			if model.ID == modelID {
514				return model, nil
515			}
516		}
517		return provider.Model{}, fmt.Errorf("unknown model %q (provider=%q)", modelID, providerName)
518	}
519	return provider.FindModel(providerName, modelID)
520}
521
522func acceptsUnlistedModel(providerName string, args Args) bool {
523	if providerName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
524		return !args.modelCatalogAuthoritative
525	}
526	return provider.AcceptsUnlistedModels(providerName)
527}
528
529func defaultModelForResolve(providerName string, args Args) string {
530	if providerName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
531		if len(args.modelCatalog) > 0 {
532			return args.modelCatalog[0].ID
533		}
534		return openCodeGoBootstrapModel
535	}
536	return defaultModelForProvider(providerName)
537}
538
539// Resolve merges args, config, and env into a Resolved set.
540//
541// Unlike the earlier version, Resolve NEVER returns an error for
542// missing credentials: the TUI can start without them and launch a
543// login flow. requireCred controls whether missing credentials are a
544// hard error (used by print/json modes).
545func Resolve(args Args, requireCred bool) (Resolved, error) {
546	cfg, cfgErr := LoadConfig()
547	profileSlot := cfg.applyActiveModelProfile()
548	repairConfig := false
549	// Programmatic callers historically supplied Tools without the parser's
550	// provenance bit. Treat a non-empty list as explicit before a named profile
551	// can contribute its defaults.
552	if len(args.Tools) > 0 {
553		args.ToolsSet = true
554	}
555	webSearchPolicy := resolveWebSearchPolicy(args, cfg, cfgErr)
556	args.WebSearchPolicy = webSearchPolicy
557
558	// User-requested provider (explicit > config > default).
559	// Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
560	// before validation so an alias is never mistaken for an unknown
561	// provider and silently downgraded to anthropic.
562	argProvider := canonicalProvider(args.Provider)
563	provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
564	if !isKnownProvider(provName) {
565		// Unknown provider (maybe removed or renamed). Fall back to
566		// the first provider that has credentials, or anthropic.
567		// Custom providers (from models.json) are already accepted
568		// by isKnownProvider, so we only reach here for truly unknown
569		// names.
570		provName = "anthropic"
571		if CredentialAvailable("openai") {
572			provName = "openai"
573		}
574		if CredentialAvailable("openai-codex") {
575			provName = "openai-codex"
576		}
577		if CredentialAvailable("kimi") {
578			provName = "kimi"
579		}
580		if CredentialAvailable("deepseek") {
581			provName = "deepseek"
582		}
583		if CredentialAvailable("anthropic") {
584			provName = "anthropic"
585		}
586		// Reset the saved config so this doesn't keep happening.
587		cfg.Provider = provName
588		cfg.Model = ""
589		repairConfig = true
590	}
591
592	var (
593		cred      string
594		method    string
595		accountID string
596		credErr   error
597		// hasRealOllamaCred tracks whether ResolveCredentialFull returned a
598		// genuine ollama credential (--api-key, OLLAMA_API_KEY, auth.json).
599		// It drives the cloud-vs-local endpoint default below; the dummy
600		// "ollama" credential never triggers the cloud default.
601		hasRealOllamaCred bool
602	)
603	if provName == "ollama" {
604		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
605		if credErr != nil {
606			// Local ollama needs no key: fall back to the dummy
607			// credential instead of failing resolution.
608			cred, method, accountID, credErr = "ollama", "apikey", "", nil
609		} else {
610			hasRealOllamaCred = true
611		}
612	} else {
613		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
614	}
615
616	// Persist --api-key for custom providers so subsequent runs don't
617	// need to pass it again.
618	if !isBuiltinProvider(provName) && args.APIKey != "" {
619		if store := AuthStoreFor(); store != nil {
620			_ = store.SetAPIKey(provName, args.APIKey)
621		}
622	}
623
624	// If the user did NOT explicitly pick a provider (neither via --provider
625	// nor by saving one in config.json) and the default one has no
626	// credentials, auto-fall-back to whichever provider is actually logged
627	// in. That way running plain `zut` after `/login` (any provider) never
628	// shows a "not logged in" banner.
629	//
630	// Critical: when the user HAS saved a provider in config.json (e.g.
631	// "deepseek" with "deepseek-v4-flash"), that choice is respected even
632	// if no credential is found — the tui will show the login dialog or
633	// surface a clear credential error rather than silently switching to
634	// a completely different provider and model.
635	// autoFellBack tracks whether the provider was changed by the
636	// auto-fallback loop (no explicit provider from CLI or config,
637	// and the default had no credentials). When false, the provider
638	// came from the user's explicit choice (--provider or config.json)
639	// and must be respected, including any model id that may belong
640	// to a different provider in the catalog — for example when using
641	// a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash".
642	var autoFellBack bool
643	userPickedProvider := args.Provider != "" || cfg.Provider != ""
644	if credErr != nil && !userPickedProvider && provName != "ollama" {
645		autoFellBack = true
646		// Scan every known provider (not a hardcoded subset) so any
647		// env-based credential is discovered, e.g. an env-only
648		// amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
649		// IAM keys) when no config.json pins the provider, such as after
650		// pointing ZUT_HOME at a fresh home dir. Iteration order of
651		// knownProviders defines fallback priority. Local providers without a
652		// default model are skipped because selecting either one here would
653		// fail before the user can choose a model.
654		for _, other := range knownProviders {
655			if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {
656				continue
657			}
658			if !CredentialAvailable(other) {
659				continue
660			}
661			// An explicit launch key belongs to the initially selected provider;
662			// automatic fallback must never reuse it for another provider.
663			c, m, a, err := ResolveCredentialFull(other, "")
664			provName = other
665			cred, method, accountID, credErr = c, m, a, err
666			break
667		}
668	}
669
670	var customProviderConfig *provider.CustomProviderConfig
671	if custom, ok := provider.CustomProviders()[provName]; ok {
672		customProviderConfig = &custom
673	}
674
675	// Normalize model ids at the resolver boundary without mutating the
676	// caller's Args value. Treat whitespace-only values as missing so a
677	// configured fallback can still be selected.
678	model := strings.TrimSpace(args.Model)
679	if model == "" {
680		model = strings.TrimSpace(cfg.Model)
681	}
682	if model == "" {
683		if provName == "ollama" || provName == provider.LlamaCPPProviderID {
684			return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
685		}
686		if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
687			// Keep model selection inside the runtime snapshot instead of
688			// consulting another runtime's mutable catalog.
689			model = defaultModelForResolve(provName, args)
690		} else {
691			model = defaultModelForProvider(provName)
692		}
693	}
694	// If the resolved model belongs to a different provider (e.g. config
695	// says gpt-5 but we auto-fell back to anthropic), pick that provider's
696	// default. This override only fires when the provider was auto-fallback-
697	// changed, NOT when the user explicitly configured the pair — gateway/
698	// router providers (openrouter, vercel-ai-gateway, etc.) can serve
699	// models from any provider in the catalog.
700	if autoFellBack && provName != "ollama" {
701		if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
702			if _, err := findModelForResolve(provName, model, args.modelCatalog); err != nil {
703				model = defaultModelForResolve(provName, args)
704			}
705		} else if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
706			model = defaultModelForProvider(provName)
707		}
708	}
709	resolvedModel, err := findModelForResolve(provName, model, args.modelCatalog)
710	if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) {
711		// Local providers are intentionally open-catalogue: any model id the
712		// configured server understands is valid, even if it is not cached.
713		resolvedModel = provider.Model{
714			Provider:      provName,
715			ID:            model,
716			DisplayName:   model,
717			ContextWindow: 128000,
718			MaxOutput:     16384,
719			BaseURL:       args.BaseURL,
720			Source:        provName,
721		}
722		err = nil
723	}
724	// OpenCode Go publishes its authoritative model list at runtime. Keep
725	// explicit model ids usable during the first refresh or while offline;
726	// the provider request will be the final authority if no catalog data is
727	// available yet.
728	if err != nil && acceptsUnlistedModel(provName, args) {
729		resolvedModel = provider.DynamicOpenCodeGoModel(provName, model, args.BaseURL)
730		err = nil
731	}
732	// Custom providers are open-catalogue like ollama: any model id the
733	// endpoint understands is valid. Use the provider-level base URL.
734	if err != nil && customProviderConfig != nil {
735		resolvedModel = provider.Model{
736			Provider:      provName,
737			ID:            model,
738			DisplayName:   model,
739			ContextWindow: 128000,
740			MaxOutput:     16384,
741			BaseURL:       customProviderConfig.BaseURL,
742			Source:        "user",
743		}
744		err = nil
745	}
746	if customProviderConfig != nil && err == nil && resolvedModel.BaseURL == "" && customProviderConfig.BaseURL != "" {
747		// Fall back to the provider-level base URL when the model does
748		// not define its own endpoint.
749		resolvedModel.BaseURL = customProviderConfig.BaseURL
750	}
751	if err != nil {
752		// The model the user (or persisted config) asked for is no
753		// longer in the active catalogue — they probably removed it
754		// from their models.json or upgraded zut and the id changed.
755		// Refusing to launch is the wrong move: it strands the user
756		// with no way to even open the TUI and pick a new model.
757		// Fall back to the provider's default, warn on stderr, and,
758		// when the stale id came from the persisted config (not an
759		// explicit --model flag), repair the config so the warning
760		// doesn't repeat on every launch.
761
762		// Gateway providers can accept route-qualified ids that are not in
763		// zut's local catalog yet, for example OpenRouter's
764		// "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
765		// plain unknown values are likely typos and should still fall back.
766		if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
767			resolvedModel = provider.Model{
768				Provider:      provName,
769				ID:            model,
770				DisplayName:   model,
771				ContextWindow: 1000000,
772				MaxOutput:     64000,
773				BaseURL:       "",
774				Source:        "gateway",
775			}
776			// Don't repair config — the routed model id may be valid upstream.
777		} else {
778			fallback := defaultModelForResolve(provName, args)
779			fm, ferr := findModelForResolve(provName, fallback, args.modelCatalog)
780			if ferr != nil {
781				// Even the provider default is gone (catastrophic
782				// catalogue trim). Last resort: any model on this
783				// provider, then the global DefaultModel. A scoped
784				// OpenCode Go runtime must stay inside its snapshot.
785				candidates := provider.ModelsForProvider(provName)
786				if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
787					candidates = args.modelCatalog
788				}
789				if len(candidates) > 0 {
790					fm = candidates[0]
791				} else {
792					fm = provider.DefaultModel
793				}
794			}
795			fmt.Fprintf(os.Stderr,
796				"zut: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
797				model, fm.ID)
798			if strings.TrimSpace(args.Model) == "" && strings.TrimSpace(cfg.Model) == model {
799				cfg.Model = fm.ID
800				repairConfig = true
801			}
802			resolvedModel = fm
803			model = fm.ID
804		}
805	}
806
807	if repairConfig {
808		// Repair the source favorite as well as legacy defaults, but never
809		// overwrite it because an explicit CLI override required fallback.
810		if profileSlot > 0 && args.Provider == "" && args.Model == "" {
811			cfg.Provider, cfg.Model = provName, model
812			cfg.Reasoning = provider.ClampReasoningForModel(resolvedModel, cfg.Reasoning)
813			cfg.QuickModelShortcuts[profileSlot-1] = QuickModelShortcut{
814				Provider: cfg.Provider, Model: cfg.Model, Reasoning: cfg.Reasoning,
815				FastMode: cfg.FastMode != nil && *cfg.FastMode,
816			}
817			cfg.ActiveModelProfile = profileSlot
818		}
819		_ = SaveConfig(cfg)
820	}
821
822	explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
823
824	// If the model defines a base URL (e.g. local ollama) and the
825	// user didn't pass --base-url, use the model's URL. For ollama,
826	// keep http://localhost:11434 as a fallback only after the model
827	// metadata has had a chance to provide a custom baseUrl.
828	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
829		args.BaseURL = resolvedModel.BaseURL
830	}
831	if args.BaseURL == "" && provName == "ollama" {
832		// A real ollama credential with no explicit endpoint means
833		// Ollama Cloud; without one, stay on the local server.
834		// Explicit --base-url and model-level baseUrl (applied above)
835		// both win over this default.
836		if hasRealOllamaCred {
837			args.BaseURL = provider.OllamaCloudBaseURL
838		} else {
839			args.BaseURL = "http://localhost:11434"
840		}
841	}
842	if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
843		managementURL, _, configErr := ResolveLlamaCPPConfig()
844		if configErr != nil {
845			return Resolved{}, configErr
846		}
847		var baseErr error
848		args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
849		if baseErr != nil {
850			return Resolved{}, baseErr
851		}
852	}
853
854	// Insecure TLS is intentionally scoped to explicit custom endpoints.
855	// Built-in provider base URLs, auth calls, and model discovery keep normal
856	// certificate verification even when --insecure is present.
857	insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
858
859	// If the model has a base URL, credentials are optional (local
860	// models like ollama don't need real API keys).
861	if resolvedModel.BaseURL != "" && credErr != nil {
862		cred = "ollama"
863		credErr = nil
864		requireCred = false
865	}
866
867	if credErr != nil && requireCred {
868		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zut` and /login",
869			credErr, envVarName(provName))
870	}
871
872	sandbox := tools.NewSandbox(args.CWD)
873	if cfg.JailByDefault != nil && *cfg.JailByDefault {
874		sandbox.Lock()
875	}
876	if args.PermissionSet != nil {
877		sandbox.SetPermissions(args.PermissionSet)
878	}
879	lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false)
880	reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(false), cfg.LSPDiagnosticsOnEditEnabled(false))
881	maybeAddPythonTool(reg, args, args.CWD, sandbox, cfg.PythonInterpreter)
882
883	docsDir, _ := zutdocs.EnsureInstalled(ZutHome())
884
885	// Skill discovery: scan project + global locations + built-in
886	// skills shipped with the binary. If any are found, register
887	// the on-demand `skill` loader tool plus a system-prompt
888	// manifest so the model knows what's available.
889	//
890	// --no-skill bypasses the entire mechanism: no manifest in the
891	// system prompt, no `skill` tool in the registry. Useful for a
892	// clean-room run with zero extra context biasing the model.
893	var (
894		discovered    []*skills.Skill
895		skillTool     *skills.Tool
896		skillAddendum string
897		skillsEnabled bool
898	)
899	if !args.NoSkill {
900		skillsEnabled = true
901		homeDir, _ := os.UserHomeDir()
902		discovered, _ = skills.Discover(ZutHome(), args.CWD, homeDir, args.WithSkills)
903		if len(discovered) > 0 {
904			skillTool = skills.NewTool(discovered)
905			reg[skillTool.Name()] = skillTool
906			skillAddendum = skills.SystemPromptAddendum(discovered)
907		}
908	}
909	_ = skillTool
910
911	summaries := toolSummaries(reg, args)
912
913	contextFiles := []ContextFile(nil)
914	if !args.NoContextFiles {
915		contextFiles = loadAgentsContext(args.CWD, ZutHome())
916	}
917	append_ := []string(nil)
918	if cfg.PonytailModeEnabled() {
919		if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" {
920			append_ = append(append_, ponytailAddendum)
921		}
922	}
923	append_ = append(append_, args.AppendSystemPrompt...)
924	if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
925		append_ = append(append_, agentsAddendum)
926	}
927	if skillAddendum != "" {
928		append_ = append(append_, skillAddendum)
929	}
930	interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
931	primaryInteractive := interactiveMode && !args.ResidentChild
932	primaryOrchestrator := args.Orchestrate && !args.ResidentChild
933	if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
934		homeDir, _ := os.UserHomeDir()
935		profiles, _ := subagents.Discover(args.CWD, homeDir)
936		if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
937			append_ = append(append_, subagentsAddendum)
938		}
939	}
940	if primaryOrchestrator {
941		// Headless orchestration owns its strict prompt contract rather than
942		// inheriting the interactive collaboration setting.
943		append_ = append(append_, StrictOrchestratorSystemAddendumFor(subagentActionsAllowed(args)))
944	} else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
945		append_ = append(append_, ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args)))
946	} else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
947		append_ = append(append_, OnDemandSubagentsSystemAddendum)
948	}
949	// Custom system prompt resolution order:
950	//   1. --system-prompt flag (highest priority; ad-hoc per run)
951	//   2. $ZUT_HOME/SYSTEM.md (persistent user override)
952	//   3. built-in default (defaultIdentity + defaultGuidelines)
953	custom := args.SystemPrompt
954	if custom == "" {
955		custom = readUserSystemPrompt(ZutHome())
956	}
957
958	sys := BuildSystemPrompt(SystemPromptOpts{
959		CWD:        args.CWD,
960		Tools:      summaries,
961		Custom:     custom,
962		Append:     append_,
963		ZutDocsDir: docsDir,
964	})
965
966	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
967	temperature := args.Temperature
968	if temperature == nil {
969		temperature = cfg.Temperature
970	}
971	fastMode := cfg.FastMode != nil && *cfg.FastMode
972	if args.FastModeSet {
973		fastMode = args.FastMode
974	}
975	max := args.MaxSteps // 0 = unlimited
976
977	var modelCatalog []provider.Model
978	if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
979		modelCatalog = append([]provider.Model{}, args.modelCatalog...)
980		found := false
981		for _, catalogModel := range modelCatalog {
982			if catalogModel.ID == model {
983				found = true
984				break
985			}
986		}
987		if !found {
988			modelCatalog = append(modelCatalog, resolvedModel)
989		}
990	}
991
992	return Resolved{
993		Provider:                  provName,
994		Model:                     model,
995		Credential:                cred,
996		AuthMethod:                method,
997		AccountID:                 accountID,
998		BaseURL:                   args.BaseURL,
999		InsecureTLS:               insecureTLS,
1000		CWD:                       args.CWD,
1001		Reasoning:                 reasoning,
1002		Temperature:               temperature,
1003		FastMode:                  fastMode,
1004		WebSearchPolicy:           webSearchPolicy,
1005		ToolRegistry:              reg,
1006		ToolSummary:               summaries,
1007		SystemPrompt:              sys,
1008		MaxSteps:                  max,
1009		DisableRepetitionGuard:    args.DisableRepetitionGuard,
1010		ContextWindow:             resolvedModel.ContextWindow,
1011		MaxOutput:                 resolvedModel.MaxOutput,
1012		modelCatalog:              modelCatalog,
1013		modelCatalogAuthoritative: args.modelCatalogAuthoritative,
1014		customProviderConfig:      customProviderConfig,
1015		Sandbox:                   sandbox,
1016		SkillTool:                 skillTool,
1017		skillsEnabled:             skillsEnabled,
1018		ContextFiles:              contextFiles,
1019		systemAppend:              append_,
1020		systemCustom:              custom,
1021		skillAddendum:             skillAddendum,
1022		toolDescriptions:          descMapFromSummaries(summaries),
1023	}, nil
1024}
1025
1026// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
1027// trimmed contents, or "" when the file is missing / unreadable /
1028// empty. Errors are intentionally swallowed: the file is optional,
1029// and any failure to read it should fall back to the built-in
1030// default system prompt rather than crash the run.
1031func readUserSystemPrompt(zutHome string) string {
1032	if zutHome == "" {
1033		return ""
1034	}
1035	path := filepath.Join(zutHome, "SYSTEM.md")
1036	raw, err := os.ReadFile(path)
1037	if err != nil {
1038		return ""
1039	}
1040	return strings.TrimSpace(string(raw))
1041}
1042
1043// loadAgentsContext loads optional AGENTS.md instruction files. No
1044// default file is created or required: zut only includes files that
1045// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
1046// followed by project instructions from the filesystem root down to cwd.
1047func loadAgentsContext(cwd, zutHome string) []ContextFile {
1048	var files []ContextFile
1049	seen := map[string]bool{}
1050	add := func(path string) {
1051		if path == "" {
1052			return
1053		}
1054		abs, err := filepath.Abs(path)
1055		if err == nil {
1056			path = abs
1057		}
1058		if seen[path] {
1059			return
1060		}
1061		raw, err := os.ReadFile(path)
1062		if err != nil {
1063			return
1064		}
1065		content := strings.TrimSpace(string(raw))
1066		if content == "" {
1067			return
1068		}
1069		seen[path] = true
1070		files = append(files, ContextFile{Path: path, Content: content})
1071	}
1072	addFirstFromDir := func(dir string) {
1073		if dir == "" {
1074			return
1075		}
1076		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
1077			path := filepath.Join(dir, name)
1078			if _, err := os.Stat(path); err == nil {
1079				add(path)
1080				return
1081			}
1082		}
1083	}
1084
1085	addFirstFromDir(zutHome)
1086
1087	if cwd != "" {
1088		abs, err := filepath.Abs(cwd)
1089		if err == nil {
1090			cwd = abs
1091		}
1092		var dirs []string
1093		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
1094			dirs = append(dirs, dir)
1095			parent := filepath.Dir(dir)
1096			if parent == dir {
1097				break
1098			}
1099		}
1100		for i := len(dirs) - 1; i >= 0; i-- {
1101			addFirstFromDir(dirs[i])
1102		}
1103	}
1104
1105	return files
1106}
1107
1108func formatAgentsContext(files []ContextFile) string {
1109	if len(files) == 0 {
1110		return ""
1111	}
1112	var sb strings.Builder
1113	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")
1114	for _, f := range files {
1115		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
1116	}
1117	return strings.TrimSpace(sb.String())
1118}
1119
1120// descMapFromSummaries indexes the human-readable descriptions for
1121// the renderToolsSection rebuild path.
1122func descMapFromSummaries(summaries []ToolSummary) map[string]string {
1123	out := make(map[string]string, len(summaries))
1124	for _, s := range summaries {
1125		out[s.Name] = s.Description
1126	}
1127	return out
1128}
1129
1130// NewClient returns a provider.Client for r, choosing the auth mode
1131// based on r.AuthMethod. Panics if no credential is present; callers
1132// must check HasCredential() first.
1133func (r Resolved) NewClient() provider.Client {
1134	if !r.HasCredential() {
1135		panic("NewClient called without credential; check HasCredential first")
1136	}
1137	wrap := r.withHTTPClient
1138	switch r.Provider {
1139	case "ollama":
1140		return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1141	case provider.LlamaCPPProviderID:
1142		credential := r.Credential
1143		if credential == "local" {
1144			credential = ""
1145		}
1146		return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1147	case "kimi":
1148		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1149		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1150		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1151		if r.AuthMethod == "oauth" {
1152			return r.wrapWithRefresh(inner)
1153		}
1154		return inner
1155	case "moonshotai":
1156		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1157	case "moonshotai-cn":
1158		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1159	case "deepseek":
1160		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1161	case "openai":
1162		return provider.NewModelRouter("openai",
1163			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1164			map[string]provider.Client{
1165				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1166			})
1167	case "openai-codex":
1168		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1169		return r.wrapWithRefresh(inner)
1170	case "openai-responses":
1171		// Public OpenAI Responses API (api.openai.com/v1/responses) via
1172		// API key. Separate provider from `openai` (Chat Completions) and
1173		// from `openai-codex` (ChatGPT subscription OAuth).
1174		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1175	case "google":
1176		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1177	case "cerebras":
1178		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1179	case "groq":
1180		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1181	case "xai":
1182		inner := r.newXAIClient(r.Credential)
1183		if r.AuthMethod == "oauth" {
1184			return r.wrapWithRefresh(inner)
1185		}
1186		return inner
1187	case "together":
1188		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1189	case "huggingface":
1190		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1191	case "openrouter":
1192		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1193	case "zai":
1194		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1195	case "xiaomi":
1196		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1197	case "xiaomi-token-plan-ams":
1198		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1199	case "xiaomi-token-plan-cn":
1200		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1201	case "xiaomi-token-plan-sgp":
1202		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1203	case "opencode":
1204		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1205	case provider.ProviderOpenCodeGo:
1206		return wrap(provider.NewOpenCodeGoWithModels(r.Credential, r.BaseURL, r.modelCatalog))
1207	case "minimax":
1208		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1209	case "minimax-cn":
1210		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1211	case "fireworks":
1212		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1213	case "vercel-ai-gateway":
1214		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1215	case "mistral":
1216		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1217	case "amazon-bedrock":
1218		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1219	case "google-vertex":
1220		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1221	case "azure-openai-responses":
1222		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1223	case "github-copilot":
1224		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1225	case "cloudflare-workers-ai":
1226		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1227	case "cloudflare-ai-gateway":
1228		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1229	default:
1230		// Custom providers: choose wire format from the models.json api field.
1231		customConfig, ok := provider.CustomProviders()[r.Provider]
1232		if r.customProviderConfig != nil {
1233			customConfig, ok = *r.customProviderConfig, true
1234		}
1235		if ok {
1236			switch customConfig.API {
1237			case provider.APIResponses:
1238				return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1239			case "anthropic":
1240				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1241			default: // "openai"
1242				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1243			}
1244		}
1245		if r.AuthMethod == "oauth" {
1246			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1247			return r.wrapWithRefresh(inner)
1248		}
1249		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1250	}
1251}
1252
1253func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1254	if !r.InsecureTLS {
1255		return c
1256	}
1257	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1258}
1259
1260func (r Resolved) newXAIClient(token string) provider.Client {
1261	return provider.NewModelRouter("xai",
1262		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1263		map[string]provider.Client{
1264			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1265		})
1266}
1267
1268// wrapWithRefresh wraps an OAuth client so the access token is
1269// refreshed automatically before each API call. Without this, long
1270// sessions (hours) silently fail when the 1-hour token expires.
1271func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1272	provName := r.Provider
1273	tokenProvider := provName
1274	if provName == "openai-codex" {
1275		tokenProvider = "openai"
1276	}
1277	baseURL := r.BaseURL
1278	accountID := r.AccountID
1279
1280	refreshFn := func(ctx context.Context) (string, error) {
1281		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1282		if err != nil {
1283			return "", err
1284		}
1285		return tok.AccessToken, nil
1286	}
1287
1288	factory := func(token string) provider.Client {
1289		switch provName {
1290		case "openai-codex":
1291			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1292		case "kimi":
1293			// anthropic-messages on api.kimi.com/coding.
1294			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1295		case "xai":
1296			return r.newXAIClient(token)
1297		default:
1298			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1299		}
1300	}
1301
1302	return provider.NewRefreshingClient(inner, refreshFn, factory)
1303}
1304
1305// UseSandbox replaces the sandbox pointer that every tool in r's
1306// registry references. Used to keep the /jail state stable across
1307// agent rebuilds (e.g. /login, /model switching providers).
1308func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1309	if s == nil || r == nil {
1310		return
1311	}
1312	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1313		s.Permissions = r.Sandbox.Permissions
1314	}
1315	r.Sandbox = s
1316	for name, t := range r.ToolRegistry {
1317		switch v := t.(type) {
1318		case *tools.ReadTool:
1319			v.Sandbox = s
1320		case *tools.WriteTool:
1321			v.Sandbox = s
1322		case *tools.EditTool:
1323			v.Sandbox = s
1324		case *tools.BashTool:
1325			v.Sandbox = s
1326		case *tools.PythonTool:
1327			v.Sandbox = s
1328		case *tools.WorktreeTool:
1329			if v.Create != nil {
1330				v.Create.Sandbox = s
1331			}
1332			if v.Manage != nil {
1333				v.Manage.Sandbox = s
1334			}
1335		case *tools.LSPTool:
1336			v.Sandbox = s
1337		case *tools.GrepTool:
1338			v.Sandbox = s
1339		case *tools.GlobTool:
1340			v.Sandbox = s
1341		case *tools.ASTTool:
1342			v.Sandbox = s
1343		}
1344		_ = name
1345	}
1346}
1347
1348// NewAgent constructs a core.Agent from r. Requires a credential.
1349func (r Resolved) NewAgent() *core.Agent {
1350	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1351	a.MaxSteps = r.MaxSteps
1352	a.DisableRepetitionGuard = r.DisableRepetitionGuard
1353	a.ContextWindow = r.ContextWindow
1354	a.MaxTokens = r.MaxOutput
1355	a.Reasoning = r.Reasoning
1356	a.Temperature = r.Temperature
1357	a.FastMode = r.FastMode
1358	if _, ok := r.ToolRegistry["worktree"].(*tools.WorktreeTool); ok {
1359		cwd := r.CWD
1360		a.HostContext = func(ctx context.Context) string {
1361			return tools.WorktreeInventoryContext(ctx, cwd)
1362		}
1363	}
1364	return a
1365}
1366
1367func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1368	if args.NoTools {
1369		return core.Registry{}
1370	}
1371	var manager *lsp.Manager
1372	if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1373		options := lsp.ManagerOptions{}
1374		if sandbox != nil {
1375			options.CheckWritePath = sandbox.CheckWritePath
1376		}
1377		manager = lsp.NewManagerWithOptions(options)
1378	}
1379	all := map[string]core.Tool{
1380		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1381		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1382		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1383		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1384		"worktree": &tools.WorktreeTool{
1385			Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")},
1386			Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox},
1387		},
1388		"grep":        &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1389		"glob":        &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1390		"ast":         &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1391		"update_goal": &tools.UpdateGoalTool{},
1392		"plan":        &tools.PlanTool{},
1393	}
1394	if webSearchAllowedForRegistry(args) {
1395		for name, tool := range tools.NewWebTools() {
1396			all[name] = tool
1397		}
1398	}
1399	if manager != nil {
1400		lspTool := tools.NewLSPTool(cwd, manager)
1401		lspTool.Sandbox = sandbox
1402		lspTool.LSPDiagnostics = diagnosticsOnEdit
1403		all["lsp"] = lspTool
1404	}
1405	reg := core.Registry{}
1406	if len(args.Tools) == 0 {
1407		for _, t := range all {
1408			reg[t.Name()] = t
1409		}
1410	} else {
1411		for _, name := range args.Tools {
1412			if name == "web_search" {
1413				for webName, tool := range all {
1414					if tools.IsWebCapabilityName(webName) {
1415						reg[webName] = tool
1416					}
1417				}
1418				continue
1419			}
1420			if t, ok := all[name]; ok {
1421				reg[name] = t
1422			}
1423		}
1424	}
1425	if orchestratorExplorationToolsAllowed(args) {
1426		reg["read"] = all["read"]
1427		reg["grep"] = all["grep"]
1428		reg["glob"] = all["glob"]
1429		if astTool, ok := all["ast"].(*tools.ASTTool); ok {
1430			astTool.ReadOnly = true
1431			reg["ast"] = astTool
1432		}
1433		if args.PermissionSet == nil {
1434			for name, tool := range tools.NewWebTools() {
1435				reg[name] = tool
1436			}
1437		}
1438	}
1439	return reg
1440}
1441
1442// pythonToolAllowed reports whether the python tool belongs in a registry
1443// built for args. Python requires the absence of a packaged-agent
1444// PermissionSet and either no explicit tool selection (the omitted --tools
1445// default) or explicit inclusion of "python" in a nonempty list. An
1446// explicitly empty selection enables no Python even though other built-in
1447// tools retain their historical empty-list behavior.
1448func pythonToolAllowed(args Args) bool {
1449	if args.NoTools || args.PermissionSet != nil {
1450		return false
1451	}
1452	if len(args.Tools) == 0 {
1453		return !args.ToolsSet
1454	}
1455	return toolListContains(args.Tools, "python")
1456}
1457
1458// maybeAddPythonTool registers the python tool with its configured
1459// interpreter. Kept outside buildToolRegistry so that helper's signature
1460// stays unchanged; the interpreter stays available across rebuilds because
1461// Resolve threads the persisted config value on every call. An unavailable
1462// interpreter never blocks startup: resolution is lazy and failures surface
1463// as actionable tool errors on invocation.
1464func maybeAddPythonTool(reg core.Registry, args Args, cwd string, sandbox *tools.Sandbox, interpreter string) {
1465	if reg == nil || !pythonToolAllowed(args) {
1466		return
1467	}
1468	reg["python"] = &tools.PythonTool{CWD: cwd, Sandbox: sandbox, Interpreter: interpreter}
1469}
1470
1471func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1472	if len(args.Tools) == 0 {
1473		return true
1474	}
1475	for _, name := range args.Tools {
1476		switch name {
1477		case "lsp":
1478			return true
1479		case "write":
1480			if diagnosticsOnWrite {
1481				return true
1482			}
1483		case "edit", "ast":
1484			if diagnosticsOnEdit {
1485				return true
1486			}
1487		}
1488	}
1489	return false
1490}
1491
1492func autoSubagentsToolAllowed(args Args) bool {
1493	return subagentActionAllowed(args, tools.SubagentActionSpawn)
1494}
1495
1496// orchestratorExplorationToolsAllowed keeps the primary orchestrator's
1497// read-only exploration tools available without enabling implementation tools.
1498func orchestratorExplorationToolsAllowed(args Args) bool {
1499	return args.Orchestrate && !args.NoTools
1500}
1501
1502func autoSubagentsStatusToolAllowed(args Args) bool {
1503	return subagentActionAllowed(args, tools.SubagentActionStatus)
1504}
1505
1506func autoSubagentsStopToolAllowed(args Args) bool {
1507	return subagentActionAllowed(args, tools.SubagentActionStop)
1508}
1509
1510func autoSubagentsResumeToolAllowed(args Args) bool {
1511	return subagentActionAllowed(args, tools.SubagentActionResume)
1512}
1513
1514func autoSubagentsInterruptToolAllowed(args Args) bool {
1515	return subagentActionAllowed(args, tools.SubagentActionInterrupt)
1516}
1517
1518// subagentActionsAllowed collects the launch-time subagent action grants.
1519func subagentActionsAllowed(args Args) SubagentActions {
1520	return SubagentActions{
1521		Spawn:     autoSubagentsToolAllowed(args),
1522		Stop:      autoSubagentsStopToolAllowed(args),
1523		Resume:    autoSubagentsResumeToolAllowed(args),
1524		Interrupt: autoSubagentsInterruptToolAllowed(args),
1525	}
1526}
1527
1528func autoSubagentsAnyToolAllowed(args Args) bool {
1529	return autoSubagentsToolAllowed(args) ||
1530		autoSubagentsStatusToolAllowed(args) ||
1531		autoSubagentsStopToolAllowed(args) ||
1532		autoSubagentsResumeToolAllowed(args) ||
1533		autoSubagentsInterruptToolAllowed(args)
1534}
1535
1536// subagentActionAllowed reports whether the launch-time tool policy grants one
1537// subagent action. A bare "subagent" entry grants every action; a
1538// "subagent:<action>" entry grants that action alone. Unrecognized entries are
1539// ignored, matching how every other --tools name is treated.
1540func subagentActionAllowed(args Args, action string) bool {
1541	if args.NoTools || args.PermissionSet != nil {
1542		return false
1543	}
1544	if args.ToolsSet && len(args.Tools) == 0 {
1545		return false
1546	}
1547	if len(args.Tools) == 0 {
1548		return true
1549	}
1550	for _, name := range args.Tools {
1551		if name == tools.SubagentToolName || name == tools.SubagentToolName+":"+action {
1552			return true
1553		}
1554	}
1555	return false
1556}
1557
1558var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "plan"}
1559
1560func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1561	var out []ToolSummary
1562	for _, name := range nativeToolSummaryOrder {
1563		if t, ok := reg[name]; ok {
1564			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1565		}
1566	}
1567	return out
1568}
1569
1570func firstNonEmpty(vals ...string) string {
1571	for _, v := range vals {
1572		if v != "" {
1573			return v
1574		}
1575	}
1576	return ""
1577}
1578
1579func kimiCodeHeaders() map[string]string {
1580	host, _ := os.Hostname()
1581	if host == "" {
1582		host = "unknown"
1583	}
1584	deviceID := ""
1585	if home, err := os.UserHomeDir(); err == nil {
1586		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1587			deviceID = strings.TrimSpace(string(b))
1588		}
1589	}
1590	if deviceID == "" {
1591		deviceID = "zut"
1592	}
1593	return map[string]string{
1594		"User-Agent":         "KimiCLI/1.41.0",
1595		"X-Msh-Platform":     "kimi_cli",
1596		"X-Msh-Version":      "1.41.0",
1597		"X-Msh-Device-Name":  host,
1598		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1599		"X-Msh-Os-Version":   runtime.GOOS,
1600		"X-Msh-Device-Id":    deviceID,
1601	}
1602}
1603
1604func envVarName(provider string) string {
1605	switch provider {
1606	case "openai", "openai-codex", "openai-responses":
1607		return "OPENAI"
1608	case "kimi":
1609		return "KIMI"
1610	case "deepseek":
1611		return "DEEPSEEK"
1612	case "google":
1613		return "GEMINI"
1614	case "ollama":
1615		return "OLLAMA"
1616	case "llama.cpp":
1617		return "LLAMA"
1618	case "moonshotai", "moonshotai-cn":
1619		return "MOONSHOT"
1620	case "groq":
1621		return "GROQ"
1622	case "xai":
1623		return "XAI"
1624	case "cerebras":
1625		return "CEREBRAS"
1626	case "together":
1627		return "TOGETHER"
1628	case "huggingface":
1629		return "HF"
1630	case "openrouter":
1631		return "OPENROUTER"
1632	case "mistral":
1633		return "MISTRAL"
1634	case "zai":
1635		return "ZAI"
1636	case "xiaomi":
1637		return "XIAOMI"
1638	case "xiaomi-token-plan-ams":
1639		return "XIAOMI_TOKEN_PLAN_AMS"
1640	case "xiaomi-token-plan-cn":
1641		return "XIAOMI_TOKEN_PLAN_CN"
1642	case "xiaomi-token-plan-sgp":
1643		return "XIAOMI_TOKEN_PLAN_SGP"
1644	case "minimax":
1645		return "MINIMAX"
1646	case "minimax-cn":
1647		return "MINIMAX_CN"
1648	case "fireworks":
1649		return "FIREWORKS"
1650	case "vercel-ai-gateway":
1651		return "AI_GATEWAY"
1652	case "opencode", "opencode-go":
1653		return "OPENCODE"
1654	case "github-copilot":
1655		return "COPILOT_GITHUB_TOKEN"
1656	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1657		return "CLOUDFLARE"
1658	case "amazon-bedrock":
1659		return "AWS"
1660	case "google-vertex":
1661		return "GOOGLE_CLOUD"
1662	case "azure-openai-responses":
1663		return "AZURE_OPENAI"
1664	default:
1665		return "ANTHROPIC"
1666	}
1667}

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