v0.47.0 1ddba1e

build.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close