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