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