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