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