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