1package agent
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "runtime"
9 "sort"
10 "strings"
11
12 zutdocs "github.com/bnema/zut"
13 "github.com/bnema/zut/packages/agent/lsp"
14 "github.com/bnema/zut/packages/agent/skills"
15 "github.com/bnema/zut/packages/agent/subagents"
16 "github.com/bnema/zut/packages/agent/tools"
17 "github.com/bnema/zut/packages/core"
18 "github.com/bnema/zut/packages/provider"
19)
20
21// ContextFile is an instruction file loaded into the system prompt.
22type ContextFile struct {
23 Path string
24 Content string
25}
26
27// Resolved is the effective configuration after merging CLI, config, defaults.
28type Resolved struct {
29 Provider string
30 Model string
31 Credential string // api key or oauth access token
32 AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
33 AccountID string // ChatGPT account id (for openai oauth), "" otherwise
34 BaseURL string
35 InsecureTLS bool
36 CWD string
37 Reasoning string
38 Temperature *float32
39 FastMode bool
40 WebSearchPolicy subagents.WebSearchPolicy
41
42 ToolRegistry core.Registry
43 ToolSummary []ToolSummary
44 SystemPrompt string
45 MaxSteps int
46 Sandbox *tools.Sandbox
47
48 // ContextWindow and MaxOutput retain the effective model metadata,
49 // including metadata synthesized for valid open-catalog local and routed
50 // models that provider.FindModel cannot rediscover later.
51 ContextWindow int
52
53 // MaxOutput is the resolved model's maximum output-token budget
54 // (from the catalog). Passed to the agent so each turn requests
55 // the model's full output capacity instead of the provider's
56 // conservative default (e.g. Bedrock's 4096, which truncates
57 // long writes/edits with stopReason=length).
58 MaxOutput int
59
60 // modelCatalog is a runtime-owned snapshot for providers whose model
61 // metadata is account- or endpoint-scoped. It prevents a later SDK
62 // runtime from changing request shaping for an already-created runtime.
63 modelCatalog []provider.Model
64 modelCatalogAuthoritative bool
65
66 // customProviderConfig snapshots user-provider transport settings so
67 // client construction does not reread a mutable global registry.
68 customProviderConfig *provider.CustomProviderConfig
69
70 // SkillTool is the on-demand skill loader registered with the
71 // agent's tool registry, or nil if no SKILL.md files were
72 // discovered. Exposed so the tui can list / preview skills.
73 SkillTool *skills.Tool
74
75 // ContextFiles records the AGENTS.md files appended to SystemPrompt,
76 // in effective load order. Interactive mode uses this metadata to make
77 // otherwise invisible startup context inspectable without adding fake
78 // messages to the provider transcript.
79 ContextFiles []ContextFile
80
81 // skillsEnabled is true when discovery ran, even when it found no
82 // skills. It is false only for an explicit clean-room disable.
83 skillsEnabled bool
84
85 // Bookkeeping for MergeExtensionTools. Captured at Resolve time
86 // so the system prompt can be rebuilt later without re-running
87 // resolve.
88 systemAppend []string
89 systemCustom string
90 skillAddendum string
91 toolDescriptions map[string]string
92}
93
94// HasCredential reports whether a credential was resolved.
95func (r Resolved) HasCredential() bool { return r.Credential != "" }
96
97// ModelCatalogSnapshot returns the runtime-owned model metadata captured while
98// resolving. It is used by SDK clients whose provider catalog is scoped to a
99// credential or endpoint.
100func (r Resolved) ModelCatalogSnapshot() []provider.Model {
101 return append([]provider.Model(nil), r.modelCatalog...)
102}
103
104// ModelCatalogIsAuthoritative reports whether the runtime snapshot came from
105// a successful provider catalog that is authoritative for model IDs.
106func (r Resolved) ModelCatalogIsAuthoritative() bool {
107 return r.modelCatalogAuthoritative
108}
109
110// MergeExtensionTools folds every tool registered by an extension
111// into r's ToolRegistry and re-renders the system prompt's tool
112// summary so the model sees both built-in and extension tools.
113//
114// Idempotent: calling twice with the same manager state has no
115// effect on the second pass (existing names are preserved). Built-in
116// tools always win on conflict.
117func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
118 if mgr == nil {
119 return
120 }
121 changed := false
122 for _, info := range mgr.Tools() {
123 // Web capability and other native names remain reserved even when their
124 // policy excludes the current session. An extension must not turn a
125 // normal CLI opt-out into a differently implemented capability.
126 if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "glob" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.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 maybeAddPythonTool(reg, args, args.CWD, sandbox, cfg.PythonInterpreter)
862
863 docsDir, _ := zutdocs.EnsureInstalled(ZutHome())
864
865 // Skill discovery: scan project + global locations + built-in
866 // skills shipped with the binary. If any are found, register
867 // the on-demand `skill` loader tool plus a system-prompt
868 // manifest so the model knows what's available.
869 //
870 // --no-skill bypasses the entire mechanism: no manifest in the
871 // system prompt, no `skill` tool in the registry. Useful for a
872 // clean-room run with zero extra context biasing the model.
873 var (
874 discovered []*skills.Skill
875 skillTool *skills.Tool
876 skillAddendum string
877 skillsEnabled bool
878 )
879 if !args.NoSkill {
880 skillsEnabled = true
881 homeDir, _ := os.UserHomeDir()
882 discovered, _ = skills.Discover(ZutHome(), args.CWD, homeDir, args.WithSkills)
883 if len(discovered) > 0 {
884 skillTool = skills.NewTool(discovered)
885 reg[skillTool.Name()] = skillTool
886 skillAddendum = skills.SystemPromptAddendum(discovered)
887 }
888 }
889 _ = skillTool
890
891 summaries := toolSummaries(reg, args)
892
893 contextFiles := []ContextFile(nil)
894 if !args.NoContextFiles {
895 contextFiles = loadAgentsContext(args.CWD, ZutHome())
896 }
897 append_ := []string(nil)
898 if cfg.PonytailModeEnabled() {
899 if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" {
900 append_ = append(append_, ponytailAddendum)
901 }
902 }
903 append_ = append(append_, args.AppendSystemPrompt...)
904 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
905 append_ = append(append_, agentsAddendum)
906 }
907 if skillAddendum != "" {
908 append_ = append(append_, skillAddendum)
909 }
910 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
911 primaryInteractive := interactiveMode && !args.ResidentChild
912 primaryOrchestrator := args.Orchestrate && !args.ResidentChild
913 if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
914 homeDir, _ := os.UserHomeDir()
915 profiles, _ := subagents.Discover(args.CWD, homeDir)
916 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
917 append_ = append(append_, subagentsAddendum)
918 }
919 }
920 if primaryOrchestrator {
921 // Headless orchestration owns its strict prompt contract rather than
922 // inheriting the interactive collaboration setting.
923 append_ = append(append_, StrictOrchestratorSystemAddendumFor(
924 autoSubagentsToolAllowed(args),
925 autoSubagentsStopToolAllowed(args),
926 autoSubagentsResumeToolAllowed(args),
927 ))
928 } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
929 append_ = append(append_, ProactiveSubagentsSystemAddendumFor(
930 autoSubagentsToolAllowed(args),
931 autoSubagentsStopToolAllowed(args),
932 autoSubagentsResumeToolAllowed(args),
933 ))
934 } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
935 append_ = append(append_, OnDemandSubagentsSystemAddendum)
936 }
937 // Custom system prompt resolution order:
938 // 1. --system-prompt flag (highest priority; ad-hoc per run)
939 // 2. $ZUT_HOME/SYSTEM.md (persistent user override)
940 // 3. built-in default (defaultIdentity + defaultGuidelines)
941 custom := args.SystemPrompt
942 if custom == "" {
943 custom = readUserSystemPrompt(ZutHome())
944 }
945
946 sys := BuildSystemPrompt(SystemPromptOpts{
947 CWD: args.CWD,
948 Tools: summaries,
949 Custom: custom,
950 Append: append_,
951 ZutDocsDir: docsDir,
952 })
953
954 reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
955 temperature := args.Temperature
956 if temperature == nil {
957 temperature = cfg.Temperature
958 }
959 fastMode := cfg.FastMode != nil && *cfg.FastMode
960 if args.FastModeSet {
961 fastMode = args.FastMode
962 }
963 max := args.MaxSteps // 0 = unlimited
964
965 var modelCatalog []provider.Model
966 if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
967 modelCatalog = append([]provider.Model{}, args.modelCatalog...)
968 found := false
969 for _, catalogModel := range modelCatalog {
970 if catalogModel.ID == model {
971 found = true
972 break
973 }
974 }
975 if !found {
976 modelCatalog = append(modelCatalog, resolvedModel)
977 }
978 }
979
980 return Resolved{
981 Provider: provName,
982 Model: model,
983 Credential: cred,
984 AuthMethod: method,
985 AccountID: accountID,
986 BaseURL: args.BaseURL,
987 InsecureTLS: insecureTLS,
988 CWD: args.CWD,
989 Reasoning: reasoning,
990 Temperature: temperature,
991 FastMode: fastMode,
992 WebSearchPolicy: webSearchPolicy,
993 ToolRegistry: reg,
994 ToolSummary: summaries,
995 SystemPrompt: sys,
996 MaxSteps: max,
997 ContextWindow: resolvedModel.ContextWindow,
998 MaxOutput: resolvedModel.MaxOutput,
999 modelCatalog: modelCatalog,
1000 modelCatalogAuthoritative: args.modelCatalogAuthoritative,
1001 customProviderConfig: customProviderConfig,
1002 Sandbox: sandbox,
1003 SkillTool: skillTool,
1004 skillsEnabled: skillsEnabled,
1005 ContextFiles: contextFiles,
1006 systemAppend: append_,
1007 systemCustom: custom,
1008 skillAddendum: skillAddendum,
1009 toolDescriptions: descMapFromSummaries(summaries),
1010 }, nil
1011}
1012
1013// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
1014// trimmed contents, or "" when the file is missing / unreadable /
1015// empty. Errors are intentionally swallowed: the file is optional,
1016// and any failure to read it should fall back to the built-in
1017// default system prompt rather than crash the run.
1018func readUserSystemPrompt(zutHome string) string {
1019 if zutHome == "" {
1020 return ""
1021 }
1022 path := filepath.Join(zutHome, "SYSTEM.md")
1023 raw, err := os.ReadFile(path)
1024 if err != nil {
1025 return ""
1026 }
1027 return strings.TrimSpace(string(raw))
1028}
1029
1030// loadAgentsContext loads optional AGENTS.md instruction files. No
1031// default file is created or required: zut only includes files that
1032// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
1033// followed by project instructions from the filesystem root down to cwd.
1034func loadAgentsContext(cwd, zutHome string) []ContextFile {
1035 var files []ContextFile
1036 seen := map[string]bool{}
1037 add := func(path string) {
1038 if path == "" {
1039 return
1040 }
1041 abs, err := filepath.Abs(path)
1042 if err == nil {
1043 path = abs
1044 }
1045 if seen[path] {
1046 return
1047 }
1048 raw, err := os.ReadFile(path)
1049 if err != nil {
1050 return
1051 }
1052 content := strings.TrimSpace(string(raw))
1053 if content == "" {
1054 return
1055 }
1056 seen[path] = true
1057 files = append(files, ContextFile{Path: path, Content: content})
1058 }
1059 addFirstFromDir := func(dir string) {
1060 if dir == "" {
1061 return
1062 }
1063 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
1064 path := filepath.Join(dir, name)
1065 if _, err := os.Stat(path); err == nil {
1066 add(path)
1067 return
1068 }
1069 }
1070 }
1071
1072 addFirstFromDir(zutHome)
1073
1074 if cwd != "" {
1075 abs, err := filepath.Abs(cwd)
1076 if err == nil {
1077 cwd = abs
1078 }
1079 var dirs []string
1080 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
1081 dirs = append(dirs, dir)
1082 parent := filepath.Dir(dir)
1083 if parent == dir {
1084 break
1085 }
1086 }
1087 for i := len(dirs) - 1; i >= 0; i-- {
1088 addFirstFromDir(dirs[i])
1089 }
1090 }
1091
1092 return files
1093}
1094
1095func formatAgentsContext(files []ContextFile) string {
1096 if len(files) == 0 {
1097 return ""
1098 }
1099 var sb strings.Builder
1100 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")
1101 for _, f := range files {
1102 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
1103 }
1104 return strings.TrimSpace(sb.String())
1105}
1106
1107// descMapFromSummaries indexes the human-readable descriptions for
1108// the renderToolsSection rebuild path.
1109func descMapFromSummaries(summaries []ToolSummary) map[string]string {
1110 out := make(map[string]string, len(summaries))
1111 for _, s := range summaries {
1112 out[s.Name] = s.Description
1113 }
1114 return out
1115}
1116
1117// NewClient returns a provider.Client for r, choosing the auth mode
1118// based on r.AuthMethod. Panics if no credential is present; callers
1119// must check HasCredential() first.
1120func (r Resolved) NewClient() provider.Client {
1121 if !r.HasCredential() {
1122 panic("NewClient called without credential; check HasCredential first")
1123 }
1124 wrap := r.withHTTPClient
1125 switch r.Provider {
1126 case "ollama":
1127 return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1128 case provider.LlamaCPPProviderID:
1129 credential := r.Credential
1130 if credential == "local" {
1131 credential = ""
1132 }
1133 return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1134 case "kimi":
1135 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1136 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1137 inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1138 if r.AuthMethod == "oauth" {
1139 return r.wrapWithRefresh(inner)
1140 }
1141 return inner
1142 case "moonshotai":
1143 return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1144 case "moonshotai-cn":
1145 return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1146 case "deepseek":
1147 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1148 case "openai":
1149 return provider.NewModelRouter("openai",
1150 wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1151 map[string]provider.Client{
1152 provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1153 })
1154 case "openai-codex":
1155 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1156 return r.wrapWithRefresh(inner)
1157 case "openai-responses":
1158 // Public OpenAI Responses API (api.openai.com/v1/responses) via
1159 // API key. Separate provider from `openai` (Chat Completions) and
1160 // from `openai-codex` (ChatGPT subscription OAuth).
1161 return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1162 case "google":
1163 return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1164 case "cerebras":
1165 return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1166 case "groq":
1167 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1168 case "xai":
1169 inner := r.newXAIClient(r.Credential)
1170 if r.AuthMethod == "oauth" {
1171 return r.wrapWithRefresh(inner)
1172 }
1173 return inner
1174 case "together":
1175 return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1176 case "huggingface":
1177 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1178 case "openrouter":
1179 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1180 case "zai":
1181 return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1182 case "xiaomi":
1183 return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1184 case "xiaomi-token-plan-ams":
1185 return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1186 case "xiaomi-token-plan-cn":
1187 return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1188 case "xiaomi-token-plan-sgp":
1189 return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1190 case "opencode":
1191 return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1192 case provider.ProviderOpenCodeGo:
1193 return wrap(provider.NewOpenCodeGoWithModels(r.Credential, r.BaseURL, r.modelCatalog))
1194 case "minimax":
1195 return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1196 case "minimax-cn":
1197 return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1198 case "fireworks":
1199 return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1200 case "vercel-ai-gateway":
1201 return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1202 case "mistral":
1203 return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1204 case "amazon-bedrock":
1205 return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1206 case "google-vertex":
1207 return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1208 case "azure-openai-responses":
1209 return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1210 case "github-copilot":
1211 return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1212 case "cloudflare-workers-ai":
1213 return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1214 case "cloudflare-ai-gateway":
1215 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1216 default:
1217 // Custom providers: choose wire format from the models.json api field.
1218 customConfig, ok := provider.CustomProviders()[r.Provider]
1219 if r.customProviderConfig != nil {
1220 customConfig, ok = *r.customProviderConfig, true
1221 }
1222 if ok {
1223 switch customConfig.API {
1224 case provider.APIResponses:
1225 return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1226 case "anthropic":
1227 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1228 default: // "openai"
1229 return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1230 }
1231 }
1232 if r.AuthMethod == "oauth" {
1233 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1234 return r.wrapWithRefresh(inner)
1235 }
1236 return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1237 }
1238}
1239
1240func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1241 if !r.InsecureTLS {
1242 return c
1243 }
1244 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1245}
1246
1247func (r Resolved) newXAIClient(token string) provider.Client {
1248 return provider.NewModelRouter("xai",
1249 r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1250 map[string]provider.Client{
1251 provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1252 })
1253}
1254
1255// wrapWithRefresh wraps an OAuth client so the access token is
1256// refreshed automatically before each API call. Without this, long
1257// sessions (hours) silently fail when the 1-hour token expires.
1258func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1259 provName := r.Provider
1260 tokenProvider := provName
1261 if provName == "openai-codex" {
1262 tokenProvider = "openai"
1263 }
1264 baseURL := r.BaseURL
1265 accountID := r.AccountID
1266
1267 refreshFn := func(ctx context.Context) (string, error) {
1268 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1269 if err != nil {
1270 return "", err
1271 }
1272 return tok.AccessToken, nil
1273 }
1274
1275 factory := func(token string) provider.Client {
1276 switch provName {
1277 case "openai-codex":
1278 return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1279 case "kimi":
1280 // anthropic-messages on api.kimi.com/coding.
1281 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1282 case "xai":
1283 return r.newXAIClient(token)
1284 default:
1285 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1286 }
1287 }
1288
1289 return provider.NewRefreshingClient(inner, refreshFn, factory)
1290}
1291
1292// UseSandbox replaces the sandbox pointer that every tool in r's
1293// registry references. Used to keep the /jail state stable across
1294// agent rebuilds (e.g. /login, /model switching providers).
1295func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1296 if s == nil || r == nil {
1297 return
1298 }
1299 if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1300 s.Permissions = r.Sandbox.Permissions
1301 }
1302 r.Sandbox = s
1303 for name, t := range r.ToolRegistry {
1304 switch v := t.(type) {
1305 case *tools.ReadTool:
1306 v.Sandbox = s
1307 case *tools.WriteTool:
1308 v.Sandbox = s
1309 case *tools.EditTool:
1310 v.Sandbox = s
1311 case *tools.BashTool:
1312 v.Sandbox = s
1313 case *tools.PythonTool:
1314 v.Sandbox = s
1315 case *tools.CreateWorktreeTool:
1316 v.Sandbox = s
1317 case *tools.LSPTool:
1318 v.Sandbox = s
1319 case *tools.GrepTool:
1320 v.Sandbox = s
1321 case *tools.GlobTool:
1322 v.Sandbox = s
1323 case *tools.ASTTool:
1324 v.Sandbox = s
1325 }
1326 _ = name
1327 }
1328}
1329
1330// NewAgent constructs a core.Agent from r. Requires a credential.
1331func (r Resolved) NewAgent() *core.Agent {
1332 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1333 a.MaxSteps = r.MaxSteps
1334 a.ContextWindow = r.ContextWindow
1335 a.MaxTokens = r.MaxOutput
1336 a.Reasoning = r.Reasoning
1337 a.Temperature = r.Temperature
1338 a.FastMode = r.FastMode
1339 return a
1340}
1341
1342func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1343 if args.NoTools {
1344 return core.Registry{}
1345 }
1346 var manager *lsp.Manager
1347 if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1348 options := lsp.ManagerOptions{}
1349 if sandbox != nil {
1350 options.CheckWritePath = sandbox.CheckWritePath
1351 }
1352 manager = lsp.NewManagerWithOptions(options)
1353 }
1354 all := map[string]core.Tool{
1355 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1356 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1357 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1358 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1359 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1360 "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1361 "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1362 "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1363 "update_goal": &tools.UpdateGoalTool{},
1364 "update_plan": &tools.UpdatePlanTool{},
1365 }
1366 if webSearchAllowedForRegistry(args) {
1367 for name, tool := range tools.NewWebTools() {
1368 all[name] = tool
1369 }
1370 }
1371 if manager != nil {
1372 lspTool := tools.NewLSPTool(cwd, manager)
1373 lspTool.Sandbox = sandbox
1374 lspTool.LSPDiagnostics = diagnosticsOnEdit
1375 all["lsp"] = lspTool
1376 }
1377 reg := core.Registry{}
1378 if len(args.Tools) == 0 {
1379 for _, t := range all {
1380 reg[t.Name()] = t
1381 }
1382 } else {
1383 for _, name := range args.Tools {
1384 if name == "web_search" {
1385 for webName, tool := range all {
1386 if tools.IsWebCapabilityName(webName) {
1387 reg[webName] = tool
1388 }
1389 }
1390 continue
1391 }
1392 if t, ok := all[name]; ok {
1393 reg[name] = t
1394 }
1395 }
1396 }
1397 if orchestratorExplorationToolsAllowed(args) {
1398 reg["read"] = all["read"]
1399 reg["grep"] = all["grep"]
1400 reg["glob"] = all["glob"]
1401 if astTool, ok := all["ast"].(*tools.ASTTool); ok {
1402 astTool.ReadOnly = true
1403 reg["ast"] = astTool
1404 }
1405 if args.PermissionSet == nil {
1406 for name, tool := range tools.NewWebTools() {
1407 reg[name] = tool
1408 }
1409 }
1410 }
1411 return reg
1412}
1413
1414// pythonToolAllowed reports whether the python tool belongs in a registry
1415// built for args. Python requires the absence of a packaged-agent
1416// PermissionSet and either no explicit tool selection (the omitted --tools
1417// default) or explicit inclusion of "python" in a nonempty list. An
1418// explicitly empty selection enables no Python even though other built-in
1419// tools retain their historical empty-list behavior.
1420func pythonToolAllowed(args Args) bool {
1421 if args.NoTools || args.PermissionSet != nil {
1422 return false
1423 }
1424 if len(args.Tools) == 0 {
1425 return !args.ToolsSet
1426 }
1427 return toolListContains(args.Tools, "python")
1428}
1429
1430// maybeAddPythonTool registers the python tool with its configured
1431// interpreter. Kept outside buildToolRegistry so that helper's signature
1432// stays unchanged; the interpreter stays available across rebuilds because
1433// Resolve threads the persisted config value on every call. An unavailable
1434// interpreter never blocks startup: resolution is lazy and failures surface
1435// as actionable tool errors on invocation.
1436func maybeAddPythonTool(reg core.Registry, args Args, cwd string, sandbox *tools.Sandbox, interpreter string) {
1437 if reg == nil || !pythonToolAllowed(args) {
1438 return
1439 }
1440 reg["python"] = &tools.PythonTool{CWD: cwd, Sandbox: sandbox, Interpreter: interpreter}
1441}
1442
1443func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1444 if len(args.Tools) == 0 {
1445 return true
1446 }
1447 for _, name := range args.Tools {
1448 switch name {
1449 case "lsp":
1450 return true
1451 case "write":
1452 if diagnosticsOnWrite {
1453 return true
1454 }
1455 case "edit", "ast":
1456 if diagnosticsOnEdit {
1457 return true
1458 }
1459 }
1460 }
1461 return false
1462}
1463
1464func autoSubagentsToolAllowed(args Args) bool {
1465 return autoSubagentsToolAllowedFor(args, "subagent_spawn")
1466}
1467
1468// orchestratorExplorationToolsAllowed keeps the primary orchestrator's
1469// read-only exploration tools available without enabling implementation tools.
1470func orchestratorExplorationToolsAllowed(args Args) bool {
1471 return args.Orchestrate && !args.NoTools
1472}
1473
1474func autoSubagentsStatusToolAllowed(args Args) bool {
1475 return autoSubagentsToolAllowedFor(args, "subagent_status")
1476}
1477
1478func autoSubagentsStopToolAllowed(args Args) bool {
1479 return autoSubagentsToolAllowedFor(args, "subagent_stop")
1480}
1481
1482func autoSubagentsResumeToolAllowed(args Args) bool {
1483 return autoSubagentsToolAllowedFor(args, "subagent_resume")
1484}
1485
1486func autoSubagentsAnyToolAllowed(args Args) bool {
1487 return autoSubagentsToolAllowed(args) ||
1488 autoSubagentsStatusToolAllowed(args) ||
1489 autoSubagentsStopToolAllowed(args) ||
1490 autoSubagentsResumeToolAllowed(args)
1491}
1492
1493func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1494 if args.NoTools || args.PermissionSet != nil {
1495 return false
1496 }
1497 if args.ToolsSet && len(args.Tools) == 0 {
1498 return false
1499 }
1500 if len(args.Tools) == 0 {
1501 return true
1502 }
1503 for _, name := range args.Tools {
1504 if name == toolName {
1505 return true
1506 }
1507 }
1508 return false
1509}
1510
1511var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
1512
1513func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1514 var out []ToolSummary
1515 for _, name := range nativeToolSummaryOrder {
1516 if t, ok := reg[name]; ok {
1517 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1518 }
1519 }
1520 return out
1521}
1522
1523func firstNonEmpty(vals ...string) string {
1524 for _, v := range vals {
1525 if v != "" {
1526 return v
1527 }
1528 }
1529 return ""
1530}
1531
1532func kimiCodeHeaders() map[string]string {
1533 host, _ := os.Hostname()
1534 if host == "" {
1535 host = "unknown"
1536 }
1537 deviceID := ""
1538 if home, err := os.UserHomeDir(); err == nil {
1539 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1540 deviceID = strings.TrimSpace(string(b))
1541 }
1542 }
1543 if deviceID == "" {
1544 deviceID = "zut"
1545 }
1546 return map[string]string{
1547 "User-Agent": "KimiCLI/1.41.0",
1548 "X-Msh-Platform": "kimi_cli",
1549 "X-Msh-Version": "1.41.0",
1550 "X-Msh-Device-Name": host,
1551 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1552 "X-Msh-Os-Version": runtime.GOOS,
1553 "X-Msh-Device-Id": deviceID,
1554 }
1555}
1556
1557func envVarName(provider string) string {
1558 switch provider {
1559 case "openai", "openai-codex", "openai-responses":
1560 return "OPENAI"
1561 case "kimi":
1562 return "KIMI"
1563 case "deepseek":
1564 return "DEEPSEEK"
1565 case "google":
1566 return "GEMINI"
1567 case "ollama":
1568 return "OLLAMA"
1569 case "llama.cpp":
1570 return "LLAMA"
1571 case "moonshotai", "moonshotai-cn":
1572 return "MOONSHOT"
1573 case "groq":
1574 return "GROQ"
1575 case "xai":
1576 return "XAI"
1577 case "cerebras":
1578 return "CEREBRAS"
1579 case "together":
1580 return "TOGETHER"
1581 case "huggingface":
1582 return "HF"
1583 case "openrouter":
1584 return "OPENROUTER"
1585 case "mistral":
1586 return "MISTRAL"
1587 case "zai":
1588 return "ZAI"
1589 case "xiaomi":
1590 return "XIAOMI"
1591 case "xiaomi-token-plan-ams":
1592 return "XIAOMI_TOKEN_PLAN_AMS"
1593 case "xiaomi-token-plan-cn":
1594 return "XIAOMI_TOKEN_PLAN_CN"
1595 case "xiaomi-token-plan-sgp":
1596 return "XIAOMI_TOKEN_PLAN_SGP"
1597 case "minimax":
1598 return "MINIMAX"
1599 case "minimax-cn":
1600 return "MINIMAX_CN"
1601 case "fireworks":
1602 return "FIREWORKS"
1603 case "vercel-ai-gateway":
1604 return "AI_GATEWAY"
1605 case "opencode", "opencode-go":
1606 return "OPENCODE"
1607 case "github-copilot":
1608 return "COPILOT_GITHUB_TOKEN"
1609 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1610 return "CLOUDFLARE"
1611 case "amazon-bedrock":
1612 return "AWS"
1613 case "google-vertex":
1614 return "GOOGLE_CLOUD"
1615 case "azure-openai-responses":
1616 return "AZURE_OPENAI"
1617 default:
1618 return "ANTHROPIC"
1619 }
1620}