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(
944 autoSubagentsToolAllowed(args),
945 autoSubagentsStopToolAllowed(args),
946 autoSubagentsResumeToolAllowed(args),
947 ))
948 } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
949 append_ = append(append_, ProactiveSubagentsSystemAddendumFor(
950 autoSubagentsToolAllowed(args),
951 autoSubagentsStopToolAllowed(args),
952 autoSubagentsResumeToolAllowed(args),
953 ))
954 } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
955 append_ = append(append_, OnDemandSubagentsSystemAddendum)
956 }
957 // Custom system prompt resolution order:
958 // 1. --system-prompt flag (highest priority; ad-hoc per run)
959 // 2. $ZUT_HOME/SYSTEM.md (persistent user override)
960 // 3. built-in default (defaultIdentity + defaultGuidelines)
961 custom := args.SystemPrompt
962 if custom == "" {
963 custom = readUserSystemPrompt(ZutHome())
964 }
965
966 sys := BuildSystemPrompt(SystemPromptOpts{
967 CWD: args.CWD,
968 Tools: summaries,
969 Custom: custom,
970 Append: append_,
971 ZutDocsDir: docsDir,
972 })
973
974 reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
975 temperature := args.Temperature
976 if temperature == nil {
977 temperature = cfg.Temperature
978 }
979 fastMode := cfg.FastMode != nil && *cfg.FastMode
980 if args.FastModeSet {
981 fastMode = args.FastMode
982 }
983 max := args.MaxSteps // 0 = unlimited
984
985 var modelCatalog []provider.Model
986 if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
987 modelCatalog = append([]provider.Model{}, args.modelCatalog...)
988 found := false
989 for _, catalogModel := range modelCatalog {
990 if catalogModel.ID == model {
991 found = true
992 break
993 }
994 }
995 if !found {
996 modelCatalog = append(modelCatalog, resolvedModel)
997 }
998 }
999
1000 return Resolved{
1001 Provider: provName,
1002 Model: model,
1003 Credential: cred,
1004 AuthMethod: method,
1005 AccountID: accountID,
1006 BaseURL: args.BaseURL,
1007 InsecureTLS: insecureTLS,
1008 CWD: args.CWD,
1009 Reasoning: reasoning,
1010 Temperature: temperature,
1011 FastMode: fastMode,
1012 WebSearchPolicy: webSearchPolicy,
1013 ToolRegistry: reg,
1014 ToolSummary: summaries,
1015 SystemPrompt: sys,
1016 MaxSteps: max,
1017 DisableRepetitionGuard: args.DisableRepetitionGuard,
1018 ContextWindow: resolvedModel.ContextWindow,
1019 MaxOutput: resolvedModel.MaxOutput,
1020 modelCatalog: modelCatalog,
1021 modelCatalogAuthoritative: args.modelCatalogAuthoritative,
1022 customProviderConfig: customProviderConfig,
1023 Sandbox: sandbox,
1024 SkillTool: skillTool,
1025 skillsEnabled: skillsEnabled,
1026 ContextFiles: contextFiles,
1027 systemAppend: append_,
1028 systemCustom: custom,
1029 skillAddendum: skillAddendum,
1030 toolDescriptions: descMapFromSummaries(summaries),
1031 }, nil
1032}
1033
1034// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
1035// trimmed contents, or "" when the file is missing / unreadable /
1036// empty. Errors are intentionally swallowed: the file is optional,
1037// and any failure to read it should fall back to the built-in
1038// default system prompt rather than crash the run.
1039func readUserSystemPrompt(zutHome string) string {
1040 if zutHome == "" {
1041 return ""
1042 }
1043 path := filepath.Join(zutHome, "SYSTEM.md")
1044 raw, err := os.ReadFile(path)
1045 if err != nil {
1046 return ""
1047 }
1048 return strings.TrimSpace(string(raw))
1049}
1050
1051// loadAgentsContext loads optional AGENTS.md instruction files. No
1052// default file is created or required: zut only includes files that
1053// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
1054// followed by project instructions from the filesystem root down to cwd.
1055func loadAgentsContext(cwd, zutHome string) []ContextFile {
1056 var files []ContextFile
1057 seen := map[string]bool{}
1058 add := func(path string) {
1059 if path == "" {
1060 return
1061 }
1062 abs, err := filepath.Abs(path)
1063 if err == nil {
1064 path = abs
1065 }
1066 if seen[path] {
1067 return
1068 }
1069 raw, err := os.ReadFile(path)
1070 if err != nil {
1071 return
1072 }
1073 content := strings.TrimSpace(string(raw))
1074 if content == "" {
1075 return
1076 }
1077 seen[path] = true
1078 files = append(files, ContextFile{Path: path, Content: content})
1079 }
1080 addFirstFromDir := func(dir string) {
1081 if dir == "" {
1082 return
1083 }
1084 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
1085 path := filepath.Join(dir, name)
1086 if _, err := os.Stat(path); err == nil {
1087 add(path)
1088 return
1089 }
1090 }
1091 }
1092
1093 addFirstFromDir(zutHome)
1094
1095 if cwd != "" {
1096 abs, err := filepath.Abs(cwd)
1097 if err == nil {
1098 cwd = abs
1099 }
1100 var dirs []string
1101 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
1102 dirs = append(dirs, dir)
1103 parent := filepath.Dir(dir)
1104 if parent == dir {
1105 break
1106 }
1107 }
1108 for i := len(dirs) - 1; i >= 0; i-- {
1109 addFirstFromDir(dirs[i])
1110 }
1111 }
1112
1113 return files
1114}
1115
1116func formatAgentsContext(files []ContextFile) string {
1117 if len(files) == 0 {
1118 return ""
1119 }
1120 var sb strings.Builder
1121 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")
1122 for _, f := range files {
1123 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
1124 }
1125 return strings.TrimSpace(sb.String())
1126}
1127
1128// descMapFromSummaries indexes the human-readable descriptions for
1129// the renderToolsSection rebuild path.
1130func descMapFromSummaries(summaries []ToolSummary) map[string]string {
1131 out := make(map[string]string, len(summaries))
1132 for _, s := range summaries {
1133 out[s.Name] = s.Description
1134 }
1135 return out
1136}
1137
1138// NewClient returns a provider.Client for r, choosing the auth mode
1139// based on r.AuthMethod. Panics if no credential is present; callers
1140// must check HasCredential() first.
1141func (r Resolved) NewClient() provider.Client {
1142 if !r.HasCredential() {
1143 panic("NewClient called without credential; check HasCredential first")
1144 }
1145 wrap := r.withHTTPClient
1146 switch r.Provider {
1147 case "ollama":
1148 return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1149 case provider.LlamaCPPProviderID:
1150 credential := r.Credential
1151 if credential == "local" {
1152 credential = ""
1153 }
1154 return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1155 case "kimi":
1156 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1157 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1158 inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1159 if r.AuthMethod == "oauth" {
1160 return r.wrapWithRefresh(inner)
1161 }
1162 return inner
1163 case "moonshotai":
1164 return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1165 case "moonshotai-cn":
1166 return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1167 case "deepseek":
1168 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1169 case "openai":
1170 return provider.NewModelRouter("openai",
1171 wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1172 map[string]provider.Client{
1173 provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1174 })
1175 case "openai-codex":
1176 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1177 return r.wrapWithRefresh(inner)
1178 case "openai-responses":
1179 // Public OpenAI Responses API (api.openai.com/v1/responses) via
1180 // API key. Separate provider from `openai` (Chat Completions) and
1181 // from `openai-codex` (ChatGPT subscription OAuth).
1182 return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1183 case "google":
1184 return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1185 case "cerebras":
1186 return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1187 case "groq":
1188 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1189 case "xai":
1190 inner := r.newXAIClient(r.Credential)
1191 if r.AuthMethod == "oauth" {
1192 return r.wrapWithRefresh(inner)
1193 }
1194 return inner
1195 case "together":
1196 return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1197 case "huggingface":
1198 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1199 case "openrouter":
1200 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1201 case "zai":
1202 return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1203 case "xiaomi":
1204 return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1205 case "xiaomi-token-plan-ams":
1206 return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1207 case "xiaomi-token-plan-cn":
1208 return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1209 case "xiaomi-token-plan-sgp":
1210 return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1211 case "opencode":
1212 return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1213 case provider.ProviderOpenCodeGo:
1214 return wrap(provider.NewOpenCodeGoWithModels(r.Credential, r.BaseURL, r.modelCatalog))
1215 case "minimax":
1216 return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1217 case "minimax-cn":
1218 return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1219 case "fireworks":
1220 return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1221 case "vercel-ai-gateway":
1222 return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1223 case "mistral":
1224 return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1225 case "amazon-bedrock":
1226 return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1227 case "google-vertex":
1228 return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1229 case "azure-openai-responses":
1230 return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1231 case "github-copilot":
1232 return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1233 case "cloudflare-workers-ai":
1234 return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1235 case "cloudflare-ai-gateway":
1236 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1237 default:
1238 // Custom providers: choose wire format from the models.json api field.
1239 customConfig, ok := provider.CustomProviders()[r.Provider]
1240 if r.customProviderConfig != nil {
1241 customConfig, ok = *r.customProviderConfig, true
1242 }
1243 if ok {
1244 switch customConfig.API {
1245 case provider.APIResponses:
1246 return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1247 case "anthropic":
1248 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1249 default: // "openai"
1250 return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1251 }
1252 }
1253 if r.AuthMethod == "oauth" {
1254 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1255 return r.wrapWithRefresh(inner)
1256 }
1257 return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1258 }
1259}
1260
1261func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1262 if !r.InsecureTLS {
1263 return c
1264 }
1265 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1266}
1267
1268func (r Resolved) newXAIClient(token string) provider.Client {
1269 return provider.NewModelRouter("xai",
1270 r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1271 map[string]provider.Client{
1272 provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1273 })
1274}
1275
1276// wrapWithRefresh wraps an OAuth client so the access token is
1277// refreshed automatically before each API call. Without this, long
1278// sessions (hours) silently fail when the 1-hour token expires.
1279func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1280 provName := r.Provider
1281 tokenProvider := provName
1282 if provName == "openai-codex" {
1283 tokenProvider = "openai"
1284 }
1285 baseURL := r.BaseURL
1286 accountID := r.AccountID
1287
1288 refreshFn := func(ctx context.Context) (string, error) {
1289 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1290 if err != nil {
1291 return "", err
1292 }
1293 return tok.AccessToken, nil
1294 }
1295
1296 factory := func(token string) provider.Client {
1297 switch provName {
1298 case "openai-codex":
1299 return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1300 case "kimi":
1301 // anthropic-messages on api.kimi.com/coding.
1302 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1303 case "xai":
1304 return r.newXAIClient(token)
1305 default:
1306 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1307 }
1308 }
1309
1310 return provider.NewRefreshingClient(inner, refreshFn, factory)
1311}
1312
1313// UseSandbox replaces the sandbox pointer that every tool in r's
1314// registry references. Used to keep the /jail state stable across
1315// agent rebuilds (e.g. /login, /model switching providers).
1316func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1317 if s == nil || r == nil {
1318 return
1319 }
1320 if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1321 s.Permissions = r.Sandbox.Permissions
1322 }
1323 r.Sandbox = s
1324 for name, t := range r.ToolRegistry {
1325 switch v := t.(type) {
1326 case *tools.ReadTool:
1327 v.Sandbox = s
1328 case *tools.WriteTool:
1329 v.Sandbox = s
1330 case *tools.EditTool:
1331 v.Sandbox = s
1332 case *tools.BashTool:
1333 v.Sandbox = s
1334 case *tools.PythonTool:
1335 v.Sandbox = s
1336 case *tools.WorktreeTool:
1337 if v.Create != nil {
1338 v.Create.Sandbox = s
1339 }
1340 if v.Manage != nil {
1341 v.Manage.Sandbox = s
1342 }
1343 case *tools.LSPTool:
1344 v.Sandbox = s
1345 case *tools.GrepTool:
1346 v.Sandbox = s
1347 case *tools.GlobTool:
1348 v.Sandbox = s
1349 case *tools.ASTTool:
1350 v.Sandbox = s
1351 }
1352 _ = name
1353 }
1354}
1355
1356// NewAgent constructs a core.Agent from r. Requires a credential.
1357func (r Resolved) NewAgent() *core.Agent {
1358 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1359 a.MaxSteps = r.MaxSteps
1360 a.DisableRepetitionGuard = r.DisableRepetitionGuard
1361 a.ContextWindow = r.ContextWindow
1362 a.MaxTokens = r.MaxOutput
1363 a.Reasoning = r.Reasoning
1364 a.Temperature = r.Temperature
1365 a.FastMode = r.FastMode
1366 if _, ok := r.ToolRegistry["worktree"].(*tools.WorktreeTool); ok {
1367 cwd := r.CWD
1368 a.HostContext = func(ctx context.Context) string {
1369 return tools.WorktreeInventoryContext(ctx, cwd)
1370 }
1371 }
1372 return a
1373}
1374
1375func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1376 if args.NoTools {
1377 return core.Registry{}
1378 }
1379 var manager *lsp.Manager
1380 if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1381 options := lsp.ManagerOptions{}
1382 if sandbox != nil {
1383 options.CheckWritePath = sandbox.CheckWritePath
1384 }
1385 manager = lsp.NewManagerWithOptions(options)
1386 }
1387 all := map[string]core.Tool{
1388 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1389 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1390 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1391 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1392 "worktree": &tools.WorktreeTool{
1393 Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")},
1394 Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox},
1395 },
1396 "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1397 "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1398 "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1399 "update_goal": &tools.UpdateGoalTool{},
1400 "plan": &tools.PlanTool{},
1401 }
1402 if webSearchAllowedForRegistry(args) {
1403 for name, tool := range tools.NewWebTools() {
1404 all[name] = tool
1405 }
1406 }
1407 if manager != nil {
1408 lspTool := tools.NewLSPTool(cwd, manager)
1409 lspTool.Sandbox = sandbox
1410 lspTool.LSPDiagnostics = diagnosticsOnEdit
1411 all["lsp"] = lspTool
1412 }
1413 reg := core.Registry{}
1414 if len(args.Tools) == 0 {
1415 for _, t := range all {
1416 reg[t.Name()] = t
1417 }
1418 } else {
1419 for _, name := range args.Tools {
1420 if name == "web_search" {
1421 for webName, tool := range all {
1422 if tools.IsWebCapabilityName(webName) {
1423 reg[webName] = tool
1424 }
1425 }
1426 continue
1427 }
1428 if t, ok := all[name]; ok {
1429 reg[name] = t
1430 }
1431 }
1432 }
1433 if orchestratorExplorationToolsAllowed(args) {
1434 reg["read"] = all["read"]
1435 reg["grep"] = all["grep"]
1436 reg["glob"] = all["glob"]
1437 if astTool, ok := all["ast"].(*tools.ASTTool); ok {
1438 astTool.ReadOnly = true
1439 reg["ast"] = astTool
1440 }
1441 if args.PermissionSet == nil {
1442 for name, tool := range tools.NewWebTools() {
1443 reg[name] = tool
1444 }
1445 }
1446 }
1447 return reg
1448}
1449
1450// pythonToolAllowed reports whether the python tool belongs in a registry
1451// built for args. Python requires the absence of a packaged-agent
1452// PermissionSet and either no explicit tool selection (the omitted --tools
1453// default) or explicit inclusion of "python" in a nonempty list. An
1454// explicitly empty selection enables no Python even though other built-in
1455// tools retain their historical empty-list behavior.
1456func pythonToolAllowed(args Args) bool {
1457 if args.NoTools || args.PermissionSet != nil {
1458 return false
1459 }
1460 if len(args.Tools) == 0 {
1461 return !args.ToolsSet
1462 }
1463 return toolListContains(args.Tools, "python")
1464}
1465
1466// maybeAddPythonTool registers the python tool with its configured
1467// interpreter. Kept outside buildToolRegistry so that helper's signature
1468// stays unchanged; the interpreter stays available across rebuilds because
1469// Resolve threads the persisted config value on every call. An unavailable
1470// interpreter never blocks startup: resolution is lazy and failures surface
1471// as actionable tool errors on invocation.
1472func maybeAddPythonTool(reg core.Registry, args Args, cwd string, sandbox *tools.Sandbox, interpreter string) {
1473 if reg == nil || !pythonToolAllowed(args) {
1474 return
1475 }
1476 reg["python"] = &tools.PythonTool{CWD: cwd, Sandbox: sandbox, Interpreter: interpreter}
1477}
1478
1479func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1480 if len(args.Tools) == 0 {
1481 return true
1482 }
1483 for _, name := range args.Tools {
1484 switch name {
1485 case "lsp":
1486 return true
1487 case "write":
1488 if diagnosticsOnWrite {
1489 return true
1490 }
1491 case "edit", "ast":
1492 if diagnosticsOnEdit {
1493 return true
1494 }
1495 }
1496 }
1497 return false
1498}
1499
1500func autoSubagentsToolAllowed(args Args) bool {
1501 return subagentActionAllowed(args, tools.SubagentActionSpawn)
1502}
1503
1504// orchestratorExplorationToolsAllowed keeps the primary orchestrator's
1505// read-only exploration tools available without enabling implementation tools.
1506func orchestratorExplorationToolsAllowed(args Args) bool {
1507 return args.Orchestrate && !args.NoTools
1508}
1509
1510func autoSubagentsStatusToolAllowed(args Args) bool {
1511 return subagentActionAllowed(args, tools.SubagentActionStatus)
1512}
1513
1514func autoSubagentsStopToolAllowed(args Args) bool {
1515 return subagentActionAllowed(args, tools.SubagentActionStop)
1516}
1517
1518func autoSubagentsResumeToolAllowed(args Args) bool {
1519 return subagentActionAllowed(args, tools.SubagentActionResume)
1520}
1521
1522func autoSubagentsAnyToolAllowed(args Args) bool {
1523 return autoSubagentsToolAllowed(args) ||
1524 autoSubagentsStatusToolAllowed(args) ||
1525 autoSubagentsStopToolAllowed(args) ||
1526 autoSubagentsResumeToolAllowed(args)
1527}
1528
1529// subagentActionAllowed reports whether the launch-time tool policy grants one
1530// subagent action. A bare "subagent" entry grants every action; a
1531// "subagent:<action>" entry grants that action alone. Unrecognized entries are
1532// ignored, matching how every other --tools name is treated.
1533func subagentActionAllowed(args Args, action string) bool {
1534 if args.NoTools || args.PermissionSet != nil {
1535 return false
1536 }
1537 if args.ToolsSet && len(args.Tools) == 0 {
1538 return false
1539 }
1540 if len(args.Tools) == 0 {
1541 return true
1542 }
1543 for _, name := range args.Tools {
1544 if name == tools.SubagentToolName || name == tools.SubagentToolName+":"+action {
1545 return true
1546 }
1547 }
1548 return false
1549}
1550
1551var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "plan"}
1552
1553func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1554 var out []ToolSummary
1555 for _, name := range nativeToolSummaryOrder {
1556 if t, ok := reg[name]; ok {
1557 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1558 }
1559 }
1560 return out
1561}
1562
1563func firstNonEmpty(vals ...string) string {
1564 for _, v := range vals {
1565 if v != "" {
1566 return v
1567 }
1568 }
1569 return ""
1570}
1571
1572func kimiCodeHeaders() map[string]string {
1573 host, _ := os.Hostname()
1574 if host == "" {
1575 host = "unknown"
1576 }
1577 deviceID := ""
1578 if home, err := os.UserHomeDir(); err == nil {
1579 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1580 deviceID = strings.TrimSpace(string(b))
1581 }
1582 }
1583 if deviceID == "" {
1584 deviceID = "zut"
1585 }
1586 return map[string]string{
1587 "User-Agent": "KimiCLI/1.41.0",
1588 "X-Msh-Platform": "kimi_cli",
1589 "X-Msh-Version": "1.41.0",
1590 "X-Msh-Device-Name": host,
1591 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1592 "X-Msh-Os-Version": runtime.GOOS,
1593 "X-Msh-Device-Id": deviceID,
1594 }
1595}
1596
1597func envVarName(provider string) string {
1598 switch provider {
1599 case "openai", "openai-codex", "openai-responses":
1600 return "OPENAI"
1601 case "kimi":
1602 return "KIMI"
1603 case "deepseek":
1604 return "DEEPSEEK"
1605 case "google":
1606 return "GEMINI"
1607 case "ollama":
1608 return "OLLAMA"
1609 case "llama.cpp":
1610 return "LLAMA"
1611 case "moonshotai", "moonshotai-cn":
1612 return "MOONSHOT"
1613 case "groq":
1614 return "GROQ"
1615 case "xai":
1616 return "XAI"
1617 case "cerebras":
1618 return "CEREBRAS"
1619 case "together":
1620 return "TOGETHER"
1621 case "huggingface":
1622 return "HF"
1623 case "openrouter":
1624 return "OPENROUTER"
1625 case "mistral":
1626 return "MISTRAL"
1627 case "zai":
1628 return "ZAI"
1629 case "xiaomi":
1630 return "XIAOMI"
1631 case "xiaomi-token-plan-ams":
1632 return "XIAOMI_TOKEN_PLAN_AMS"
1633 case "xiaomi-token-plan-cn":
1634 return "XIAOMI_TOKEN_PLAN_CN"
1635 case "xiaomi-token-plan-sgp":
1636 return "XIAOMI_TOKEN_PLAN_SGP"
1637 case "minimax":
1638 return "MINIMAX"
1639 case "minimax-cn":
1640 return "MINIMAX_CN"
1641 case "fireworks":
1642 return "FIREWORKS"
1643 case "vercel-ai-gateway":
1644 return "AI_GATEWAY"
1645 case "opencode", "opencode-go":
1646 return "OPENCODE"
1647 case "github-copilot":
1648 return "COPILOT_GITHUB_TOKEN"
1649 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1650 return "CLOUDFLARE"
1651 case "amazon-bedrock":
1652 return "AWS"
1653 case "google-vertex":
1654 return "GOOGLE_CLOUD"
1655 case "azure-openai-responses":
1656 return "AZURE_OPENAI"
1657 default:
1658 return "ANTHROPIC"
1659 }
1660}