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 == "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 }
1322 _ = name
1323 }
1324}
1325
1326// NewAgent constructs a core.Agent from r. Requires a credential.
1327func (r Resolved) NewAgent() *core.Agent {
1328 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1329 a.MaxSteps = r.MaxSteps
1330 a.ContextWindow = r.ContextWindow
1331 a.MaxTokens = r.MaxOutput
1332 a.Reasoning = r.Reasoning
1333 a.Temperature = r.Temperature
1334 a.FastMode = r.FastMode
1335 return a
1336}
1337
1338func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1339 if args.NoTools {
1340 return core.Registry{}
1341 }
1342 var manager *lsp.Manager
1343 if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1344 options := lsp.ManagerOptions{}
1345 if sandbox != nil {
1346 options.CheckWritePath = sandbox.CheckWritePath
1347 }
1348 manager = lsp.NewManagerWithOptions(options)
1349 }
1350 all := map[string]core.Tool{
1351 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1352 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1353 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1354 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1355 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1356 "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1357 "update_goal": &tools.UpdateGoalTool{},
1358 "update_plan": &tools.UpdatePlanTool{},
1359 }
1360 if webSearchAllowedForRegistry(args) {
1361 for name, tool := range tools.NewWebTools() {
1362 all[name] = tool
1363 }
1364 }
1365 if manager != nil {
1366 lspTool := tools.NewLSPTool(cwd, manager)
1367 lspTool.Sandbox = sandbox
1368 all["lsp"] = lspTool
1369 }
1370 reg := core.Registry{}
1371 if len(args.Tools) == 0 {
1372 for _, t := range all {
1373 reg[t.Name()] = t
1374 }
1375 } else {
1376 for _, name := range args.Tools {
1377 if name == "web_search" {
1378 for webName, tool := range all {
1379 if tools.IsWebCapabilityName(webName) {
1380 reg[webName] = tool
1381 }
1382 }
1383 continue
1384 }
1385 if t, ok := all[name]; ok {
1386 reg[name] = t
1387 }
1388 }
1389 }
1390 if orchestratorExplorationToolsAllowed(args) {
1391 reg["read"] = all["read"]
1392 reg["grep"] = all["grep"]
1393 if args.PermissionSet == nil {
1394 for name, tool := range tools.NewWebTools() {
1395 reg[name] = tool
1396 }
1397 }
1398 }
1399 return reg
1400}
1401
1402// pythonToolAllowed reports whether the python tool belongs in a registry
1403// built for args. Python requires the absence of a packaged-agent
1404// PermissionSet and either no explicit tool selection (the omitted --tools
1405// default) or explicit inclusion of "python" in a nonempty list. An
1406// explicitly empty selection enables no Python even though other built-in
1407// tools retain their historical empty-list behavior.
1408func pythonToolAllowed(args Args) bool {
1409 if args.NoTools || args.PermissionSet != nil {
1410 return false
1411 }
1412 if len(args.Tools) == 0 {
1413 return !args.ToolsSet
1414 }
1415 return toolListContains(args.Tools, "python")
1416}
1417
1418// maybeAddPythonTool registers the python tool with its configured
1419// interpreter. Kept outside buildToolRegistry so that helper's signature
1420// stays unchanged; the interpreter stays available across rebuilds because
1421// Resolve threads the persisted config value on every call. An unavailable
1422// interpreter never blocks startup: resolution is lazy and failures surface
1423// as actionable tool errors on invocation.
1424func maybeAddPythonTool(reg core.Registry, args Args, cwd string, sandbox *tools.Sandbox, interpreter string) {
1425 if reg == nil || !pythonToolAllowed(args) {
1426 return
1427 }
1428 reg["python"] = &tools.PythonTool{CWD: cwd, Sandbox: sandbox, Interpreter: interpreter}
1429}
1430
1431func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1432 if len(args.Tools) == 0 {
1433 return true
1434 }
1435 for _, name := range args.Tools {
1436 switch name {
1437 case "lsp":
1438 return true
1439 case "write":
1440 if diagnosticsOnWrite {
1441 return true
1442 }
1443 case "edit":
1444 if diagnosticsOnEdit {
1445 return true
1446 }
1447 }
1448 }
1449 return false
1450}
1451
1452func autoSubagentsToolAllowed(args Args) bool {
1453 return autoSubagentsToolAllowedFor(args, "subagent_spawn")
1454}
1455
1456// orchestratorExplorationToolsAllowed keeps the primary orchestrator's
1457// read-only exploration tools available without enabling implementation tools.
1458func orchestratorExplorationToolsAllowed(args Args) bool {
1459 return args.Orchestrate && !args.NoTools
1460}
1461
1462func autoSubagentsStatusToolAllowed(args Args) bool {
1463 return autoSubagentsToolAllowedFor(args, "subagent_status")
1464}
1465
1466func autoSubagentsStopToolAllowed(args Args) bool {
1467 return autoSubagentsToolAllowedFor(args, "subagent_stop")
1468}
1469
1470func autoSubagentsResumeToolAllowed(args Args) bool {
1471 return autoSubagentsToolAllowedFor(args, "subagent_resume")
1472}
1473
1474func autoSubagentsAnyToolAllowed(args Args) bool {
1475 return autoSubagentsToolAllowed(args) ||
1476 autoSubagentsStatusToolAllowed(args) ||
1477 autoSubagentsStopToolAllowed(args) ||
1478 autoSubagentsResumeToolAllowed(args)
1479}
1480
1481func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1482 if args.NoTools || args.PermissionSet != nil {
1483 return false
1484 }
1485 if args.ToolsSet && len(args.Tools) == 0 {
1486 return false
1487 }
1488 if len(args.Tools) == 0 {
1489 return true
1490 }
1491 for _, name := range args.Tools {
1492 if name == toolName {
1493 return true
1494 }
1495 }
1496 return false
1497}
1498
1499var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
1500
1501func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1502 var out []ToolSummary
1503 for _, name := range nativeToolSummaryOrder {
1504 if t, ok := reg[name]; ok {
1505 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1506 }
1507 }
1508 return out
1509}
1510
1511func firstNonEmpty(vals ...string) string {
1512 for _, v := range vals {
1513 if v != "" {
1514 return v
1515 }
1516 }
1517 return ""
1518}
1519
1520func kimiCodeHeaders() map[string]string {
1521 host, _ := os.Hostname()
1522 if host == "" {
1523 host = "unknown"
1524 }
1525 deviceID := ""
1526 if home, err := os.UserHomeDir(); err == nil {
1527 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1528 deviceID = strings.TrimSpace(string(b))
1529 }
1530 }
1531 if deviceID == "" {
1532 deviceID = "zut"
1533 }
1534 return map[string]string{
1535 "User-Agent": "KimiCLI/1.41.0",
1536 "X-Msh-Platform": "kimi_cli",
1537 "X-Msh-Version": "1.41.0",
1538 "X-Msh-Device-Name": host,
1539 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1540 "X-Msh-Os-Version": runtime.GOOS,
1541 "X-Msh-Device-Id": deviceID,
1542 }
1543}
1544
1545func envVarName(provider string) string {
1546 switch provider {
1547 case "openai", "openai-codex", "openai-responses":
1548 return "OPENAI"
1549 case "kimi":
1550 return "KIMI"
1551 case "deepseek":
1552 return "DEEPSEEK"
1553 case "google":
1554 return "GEMINI"
1555 case "ollama":
1556 return "OLLAMA"
1557 case "llama.cpp":
1558 return "LLAMA"
1559 case "moonshotai", "moonshotai-cn":
1560 return "MOONSHOT"
1561 case "groq":
1562 return "GROQ"
1563 case "xai":
1564 return "XAI"
1565 case "cerebras":
1566 return "CEREBRAS"
1567 case "together":
1568 return "TOGETHER"
1569 case "huggingface":
1570 return "HF"
1571 case "openrouter":
1572 return "OPENROUTER"
1573 case "mistral":
1574 return "MISTRAL"
1575 case "zai":
1576 return "ZAI"
1577 case "xiaomi":
1578 return "XIAOMI"
1579 case "xiaomi-token-plan-ams":
1580 return "XIAOMI_TOKEN_PLAN_AMS"
1581 case "xiaomi-token-plan-cn":
1582 return "XIAOMI_TOKEN_PLAN_CN"
1583 case "xiaomi-token-plan-sgp":
1584 return "XIAOMI_TOKEN_PLAN_SGP"
1585 case "minimax":
1586 return "MINIMAX"
1587 case "minimax-cn":
1588 return "MINIMAX_CN"
1589 case "fireworks":
1590 return "FIREWORKS"
1591 case "vercel-ai-gateway":
1592 return "AI_GATEWAY"
1593 case "opencode", "opencode-go":
1594 return "OPENCODE"
1595 case "github-copilot":
1596 return "COPILOT_GITHUB_TOKEN"
1597 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1598 return "CLOUDFLARE"
1599 case "amazon-bedrock":
1600 return "AWS"
1601 case "google-vertex":
1602 return "GOOGLE_CLOUD"
1603 case "azure-openai-responses":
1604 return "AZURE_OPENAI"
1605 default:
1606 return "ANTHROPIC"
1607 }
1608}