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 append_ = append(append_, args.AppendSystemPrompt...)
966 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
967 append_ = append(append_, agentsAddendum)
968 }
969 if skillAddendum != "" {
970 append_ = append(append_, skillAddendum)
971 }
972 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
973 primaryInteractive := interactiveMode && !args.ResidentChild
974 primaryOrchestrator := args.Orchestrate && !args.ResidentChild
975 if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
976 homeDir, _ := os.UserHomeDir()
977 profiles, _ := subagents.Discover(args.CWD, homeDir)
978 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
979 append_ = append(append_, subagentsAddendum)
980 }
981 }
982 if primaryOrchestrator {
983 // Headless orchestration owns its strict prompt contract rather than
984 // inheriting the interactive collaboration setting.
985 append_ = append(append_, StrictOrchestratorSystemAddendumFor(subagentActionsAllowed(args)))
986 } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
987 append_ = append(append_, ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args)))
988 } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
989 append_ = append(append_, OnDemandSubagentsSystemAddendum)
990 }
991 // Custom system prompt resolution order:
992 // 1. --system-prompt flag (highest priority; ad-hoc per run)
993 // 2. $ZUT_HOME/SYSTEM.md (persistent user override)
994 // 3. built-in default (defaultIdentity + defaultGuidelines)
995 custom := args.SystemPrompt
996 if custom == "" {
997 custom = readUserSystemPrompt(ZutHome())
998 }
999
1000 sys := BuildSystemPrompt(SystemPromptOpts{
1001 CWD: args.CWD,
1002 Tools: summaries,
1003 Custom: custom,
1004 Append: append_,
1005 ZutDocsDir: docsDir,
1006 })
1007
1008 reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
1009 temperature := args.Temperature
1010 if temperature == nil {
1011 temperature = cfg.Temperature
1012 }
1013 fastMode := cfg.FastMode != nil && *cfg.FastMode
1014 if args.FastModeSet {
1015 fastMode = args.FastMode
1016 }
1017 max := args.MaxSteps // 0 = unlimited
1018
1019 var modelCatalog []provider.Model
1020 if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil {
1021 modelCatalog = append([]provider.Model{}, args.modelCatalog...)
1022 found := false
1023 for _, catalogModel := range modelCatalog {
1024 if catalogModel.ID == model {
1025 found = true
1026 break
1027 }
1028 }
1029 if !found {
1030 modelCatalog = append(modelCatalog, resolvedModel)
1031 }
1032 }
1033
1034 return Resolved{
1035 Provider: provName,
1036 Model: model,
1037 Credential: cred,
1038 AuthMethod: method,
1039 AccountID: accountID,
1040 BaseURL: args.BaseURL,
1041 InsecureTLS: insecureTLS,
1042 CWD: args.CWD,
1043 Reasoning: reasoning,
1044 Temperature: temperature,
1045 FastMode: fastMode,
1046 WebSearchPolicy: webSearchPolicy,
1047 ToolRegistry: reg,
1048 ToolSummary: summaries,
1049 SystemPrompt: sys,
1050 MaxSteps: max,
1051 DisableRepetitionGuard: args.DisableRepetitionGuard,
1052 ContextWindow: resolvedModel.ContextWindow,
1053 MaxOutput: resolvedModel.MaxOutput,
1054 modelCatalog: modelCatalog,
1055 modelCatalogAuthoritative: args.modelCatalogAuthoritative,
1056 customProviderConfig: customProviderConfig,
1057 credentialOptional: credentialOptional,
1058 Sandbox: sandbox,
1059 SkillTool: skillTool,
1060 skillsEnabled: skillsEnabled,
1061 ContextFiles: contextFiles,
1062 systemAppend: append_,
1063 systemCustom: custom,
1064 skillAddendum: skillAddendum,
1065 toolDescriptions: descMapFromSummaries(summaries),
1066 }, nil
1067}
1068
1069// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
1070// trimmed contents, or "" when the file is missing / unreadable /
1071// empty. Errors are intentionally swallowed: the file is optional,
1072// and any failure to read it should fall back to the built-in
1073// default system prompt rather than crash the run.
1074func readUserSystemPrompt(zutHome string) string {
1075 if zutHome == "" {
1076 return ""
1077 }
1078 path := filepath.Join(zutHome, "SYSTEM.md")
1079 raw, err := os.ReadFile(path)
1080 if err != nil {
1081 return ""
1082 }
1083 return strings.TrimSpace(string(raw))
1084}
1085
1086// loadAgentsContext loads optional AGENTS.md instruction files. No
1087// default file is created or required: zut only includes files that
1088// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
1089// followed by project instructions from the filesystem root down to cwd.
1090func loadAgentsContext(cwd, zutHome string) []ContextFile {
1091 var files []ContextFile
1092 seen := map[string]bool{}
1093 add := func(path string) {
1094 if path == "" {
1095 return
1096 }
1097 abs, err := filepath.Abs(path)
1098 if err == nil {
1099 path = abs
1100 }
1101 if seen[path] {
1102 return
1103 }
1104 raw, err := os.ReadFile(path)
1105 if err != nil {
1106 return
1107 }
1108 content := strings.TrimSpace(string(raw))
1109 if content == "" {
1110 return
1111 }
1112 seen[path] = true
1113 files = append(files, ContextFile{Path: path, Content: content})
1114 }
1115 addFirstFromDir := func(dir string) {
1116 if dir == "" {
1117 return
1118 }
1119 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
1120 path := filepath.Join(dir, name)
1121 if _, err := os.Stat(path); err == nil {
1122 add(path)
1123 return
1124 }
1125 }
1126 }
1127
1128 addFirstFromDir(zutHome)
1129
1130 if cwd != "" {
1131 abs, err := filepath.Abs(cwd)
1132 if err == nil {
1133 cwd = abs
1134 }
1135 var dirs []string
1136 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
1137 dirs = append(dirs, dir)
1138 parent := filepath.Dir(dir)
1139 if parent == dir {
1140 break
1141 }
1142 }
1143 for i := len(dirs) - 1; i >= 0; i-- {
1144 addFirstFromDir(dirs[i])
1145 }
1146 }
1147
1148 return files
1149}
1150
1151func formatAgentsContext(files []ContextFile) string {
1152 if len(files) == 0 {
1153 return ""
1154 }
1155 var sb strings.Builder
1156 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")
1157 for _, f := range files {
1158 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
1159 }
1160 return strings.TrimSpace(sb.String())
1161}
1162
1163// descMapFromSummaries indexes the human-readable descriptions for
1164// the renderToolsSection rebuild path.
1165func descMapFromSummaries(summaries []ToolSummary) map[string]string {
1166 out := make(map[string]string, len(summaries))
1167 for _, s := range summaries {
1168 out[s.Name] = s.Description
1169 }
1170 return out
1171}
1172
1173// NewClient returns a provider.Client for r, choosing the auth mode
1174// based on r.AuthMethod. Panics if no credential is present; callers
1175// must check HasCredential() first.
1176func (r Resolved) NewClient() provider.Client {
1177 if !r.HasCredential() {
1178 panic("NewClient called without credential or keyless endpoint; check HasCredential first")
1179 }
1180 wrap := r.withHTTPClient
1181 switch r.Provider {
1182 case "ollama":
1183 return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1184 case provider.LlamaCPPProviderID:
1185 credential := r.Credential
1186 if credential == "local" {
1187 credential = ""
1188 }
1189 return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1190 case "kimi":
1191 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1192 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1193 inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1194 if r.AuthMethod == "oauth" {
1195 return r.wrapWithRefresh(inner)
1196 }
1197 return inner
1198 case "moonshotai":
1199 return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1200 case "moonshotai-cn":
1201 return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1202 case "deepseek":
1203 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1204 case "openai":
1205 return provider.NewModelRouter("openai",
1206 wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1207 map[string]provider.Client{
1208 provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1209 })
1210 case "openai-codex":
1211 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1212 return r.wrapWithRefresh(inner)
1213 case "openai-responses":
1214 // Public OpenAI Responses API (api.openai.com/v1/responses) via
1215 // API key. Separate provider from `openai` (Chat Completions) and
1216 // from `openai-codex` (ChatGPT subscription OAuth).
1217 return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1218 case "google":
1219 return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1220 case "cerebras":
1221 return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1222 case "groq":
1223 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1224 case "xai":
1225 inner := r.newXAIClient(r.Credential)
1226 if r.AuthMethod == "oauth" {
1227 return r.wrapWithRefresh(inner)
1228 }
1229 return inner
1230 case "together":
1231 return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1232 case "huggingface":
1233 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1234 case "openrouter":
1235 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1236 case "zai":
1237 return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1238 case "xiaomi":
1239 return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1240 case "xiaomi-token-plan-ams":
1241 return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1242 case "xiaomi-token-plan-cn":
1243 return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1244 case "xiaomi-token-plan-sgp":
1245 return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1246 case "opencode":
1247 return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1248 case provider.ProviderOpenCodeGo:
1249 return wrap(provider.NewOpenCodeGoWithModels(r.Credential, r.BaseURL, r.modelCatalog))
1250 case "minimax":
1251 return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1252 case "minimax-cn":
1253 return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1254 case "fireworks":
1255 return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1256 case "vercel-ai-gateway":
1257 return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1258 case "mistral":
1259 return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1260 case "amazon-bedrock":
1261 return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1262 case "google-vertex":
1263 return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1264 case "azure-openai-responses":
1265 return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1266 case "github-copilot":
1267 return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1268 case "cloudflare-workers-ai":
1269 return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1270 case "cloudflare-ai-gateway":
1271 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1272 default:
1273 // Custom providers: choose wire format from the models.json api field.
1274 customConfig, ok := provider.CustomProviders()[r.Provider]
1275 if r.customProviderConfig != nil {
1276 customConfig, ok = *r.customProviderConfig, true
1277 }
1278 if ok {
1279 switch customConfig.API {
1280 case provider.APIResponses:
1281 return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1282 case "anthropic":
1283 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1284 default: // "openai"
1285 return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1286 }
1287 }
1288 if r.AuthMethod == "oauth" {
1289 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1290 return r.wrapWithRefresh(inner)
1291 }
1292 return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1293 }
1294}
1295
1296func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1297 if !r.InsecureTLS {
1298 return c
1299 }
1300 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1301}
1302
1303func (r Resolved) newXAIClient(token string) provider.Client {
1304 return provider.NewModelRouter("xai",
1305 r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1306 map[string]provider.Client{
1307 provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1308 })
1309}
1310
1311// wrapWithRefresh wraps an OAuth client so the access token is
1312// refreshed automatically before each API call. Without this, long
1313// sessions (hours) silently fail when the 1-hour token expires.
1314func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1315 provName := r.Provider
1316 tokenProvider := provName
1317 if provName == "openai-codex" {
1318 tokenProvider = "openai"
1319 }
1320 baseURL := r.BaseURL
1321 accountID := r.AccountID
1322
1323 refreshFn := func(ctx context.Context) (string, error) {
1324 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1325 if err != nil {
1326 return "", err
1327 }
1328 return tok.AccessToken, nil
1329 }
1330
1331 factory := func(token string) provider.Client {
1332 switch provName {
1333 case "openai-codex":
1334 return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1335 case "kimi":
1336 // anthropic-messages on api.kimi.com/coding.
1337 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1338 case "xai":
1339 return r.newXAIClient(token)
1340 default:
1341 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1342 }
1343 }
1344
1345 return provider.NewRefreshingClient(inner, refreshFn, factory)
1346}
1347
1348// UseSandbox replaces the sandbox pointer that every tool in r's
1349// registry references. Used to keep the /jail state stable across
1350// agent rebuilds (e.g. /login, /model switching providers).
1351func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1352 if s == nil || r == nil {
1353 return
1354 }
1355 if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1356 s.Permissions = r.Sandbox.Permissions
1357 }
1358 r.Sandbox = s
1359 for name, t := range r.ToolRegistry {
1360 switch v := t.(type) {
1361 case *tools.ReadTool:
1362 v.Sandbox = s
1363 case *tools.WriteTool:
1364 v.Sandbox = s
1365 case *tools.EditTool:
1366 v.Sandbox = s
1367 case *tools.BashTool:
1368 v.Sandbox = s
1369 case *tools.PythonTool:
1370 v.Sandbox = s
1371 case *tools.WorktreeTool:
1372 if v.Create != nil {
1373 v.Create.Sandbox = s
1374 }
1375 if v.Manage != nil {
1376 v.Manage.Sandbox = s
1377 }
1378 case *tools.LSPTool:
1379 v.Sandbox = s
1380 case *tools.GrepTool:
1381 v.Sandbox = s
1382 case *tools.GlobTool:
1383 v.Sandbox = s
1384 case *tools.ASTTool:
1385 v.Sandbox = s
1386 }
1387 _ = name
1388 }
1389}
1390
1391// NewAgent constructs a core.Agent from r. Requires a credential.
1392func (r Resolved) NewAgent() *core.Agent {
1393 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1394 a.MaxSteps = r.MaxSteps
1395 a.DisableRepetitionGuard = r.DisableRepetitionGuard
1396 a.ContextWindow = r.ContextWindow
1397 a.MaxTokens = r.MaxOutput
1398 a.Reasoning = r.Reasoning
1399 a.Temperature = r.Temperature
1400 a.FastMode = r.FastMode
1401 if _, ok := r.ToolRegistry["worktree"].(*tools.WorktreeTool); ok {
1402 cwd := r.CWD
1403 a.HostContext = func(ctx context.Context) string {
1404 return tools.WorktreeInventoryContext(ctx, cwd)
1405 }
1406 }
1407 return a
1408}
1409
1410func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1411 if args.NoTools {
1412 return core.Registry{}
1413 }
1414 var manager *lsp.Manager
1415 if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1416 options := lsp.ManagerOptions{}
1417 if sandbox != nil {
1418 options.CheckWritePath = sandbox.CheckWritePath
1419 }
1420 manager = lsp.NewManagerWithOptions(options)
1421 }
1422 all := map[string]core.Tool{
1423 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1424 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1425 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1426 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1427 "worktree": &tools.WorktreeTool{
1428 Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")},
1429 Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox},
1430 },
1431 "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1432 "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1433 "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1434 "update_goal": &tools.UpdateGoalTool{},
1435 "plan": &tools.PlanTool{},
1436 }
1437 if webSearchAllowedForRegistry(args) {
1438 for name, tool := range tools.NewWebTools() {
1439 all[name] = tool
1440 }
1441 }
1442 if manager != nil {
1443 lspTool := tools.NewLSPTool(cwd, manager)
1444 lspTool.Sandbox = sandbox
1445 lspTool.LSPDiagnostics = diagnosticsOnEdit
1446 all["lsp"] = lspTool
1447 }
1448 reg := core.Registry{}
1449 if len(args.Tools) == 0 {
1450 for _, t := range all {
1451 reg[t.Name()] = t
1452 }
1453 } else {
1454 for _, name := range args.Tools {
1455 if name == "web_search" {
1456 for webName, tool := range all {
1457 if tools.IsWebCapabilityName(webName) {
1458 reg[webName] = tool
1459 }
1460 }
1461 continue
1462 }
1463 if t, ok := all[name]; ok {
1464 reg[name] = t
1465 }
1466 }
1467 }
1468 if orchestratorExplorationToolsAllowed(args) {
1469 reg["read"] = all["read"]
1470 reg["grep"] = all["grep"]
1471 reg["glob"] = all["glob"]
1472 if astTool, ok := all["ast"].(*tools.ASTTool); ok {
1473 astTool.ReadOnly = true
1474 reg["ast"] = astTool
1475 }
1476 if args.PermissionSet == nil {
1477 for name, tool := range tools.NewWebTools() {
1478 reg[name] = tool
1479 }
1480 }
1481 }
1482 grepTool, hasGrep := reg["grep"].(*tools.GrepTool)
1483 astTool, hasAST := reg["ast"].(*tools.ASTTool)
1484 if hasGrep && hasAST {
1485 grepTool.ASTAvailable = sync.OnceValue(astTool.Available)
1486 }
1487 return reg
1488}
1489
1490// pythonToolAllowed reports whether the python tool belongs in a registry
1491// built for args. Python requires the absence of a packaged-agent
1492// PermissionSet and either no explicit tool selection (the omitted --tools
1493// default) or explicit inclusion of "python" in a nonempty list. An
1494// explicitly empty selection enables no Python even though other built-in
1495// tools retain their historical empty-list behavior.
1496func pythonToolAllowed(args Args) bool {
1497 if args.NoTools || args.PermissionSet != nil {
1498 return false
1499 }
1500 if len(args.Tools) == 0 {
1501 return !args.ToolsSet
1502 }
1503 return toolListContains(args.Tools, "python")
1504}
1505
1506// maybeAddPythonTool registers the python tool with its configured
1507// interpreter. Kept outside buildToolRegistry so that helper's signature
1508// stays unchanged; the interpreter stays available across rebuilds because
1509// Resolve threads the persisted config value on every call. An unavailable
1510// interpreter never blocks startup: resolution is lazy and failures surface
1511// as actionable tool errors on invocation.
1512func maybeAddPythonTool(reg core.Registry, args Args, cwd string, sandbox *tools.Sandbox, interpreter string) {
1513 if reg == nil || !pythonToolAllowed(args) {
1514 return
1515 }
1516 reg["python"] = &tools.PythonTool{CWD: cwd, Sandbox: sandbox, Interpreter: interpreter}
1517}
1518
1519func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1520 if len(args.Tools) == 0 {
1521 return true
1522 }
1523 for _, name := range args.Tools {
1524 switch name {
1525 case "lsp":
1526 return true
1527 case "write":
1528 if diagnosticsOnWrite {
1529 return true
1530 }
1531 case "edit", "ast":
1532 if diagnosticsOnEdit {
1533 return true
1534 }
1535 }
1536 }
1537 return false
1538}
1539
1540func autoSubagentsToolAllowed(args Args) bool {
1541 return subagentActionAllowed(args, tools.SubagentActionSpawn)
1542}
1543
1544// orchestratorExplorationToolsAllowed keeps the primary orchestrator's
1545// read-only exploration tools available without enabling implementation tools.
1546func orchestratorExplorationToolsAllowed(args Args) bool {
1547 return args.Orchestrate && !args.NoTools
1548}
1549
1550func autoSubagentsStatusToolAllowed(args Args) bool {
1551 return subagentActionAllowed(args, tools.SubagentActionStatus)
1552}
1553
1554func autoSubagentsStopToolAllowed(args Args) bool {
1555 return subagentActionAllowed(args, tools.SubagentActionStop)
1556}
1557
1558func autoSubagentsResumeToolAllowed(args Args) bool {
1559 return subagentActionAllowed(args, tools.SubagentActionResume)
1560}
1561
1562func autoSubagentsInterruptToolAllowed(args Args) bool {
1563 return subagentActionAllowed(args, tools.SubagentActionInterrupt)
1564}
1565
1566// subagentActionsAllowed collects the launch-time subagent action grants.
1567func subagentActionsAllowed(args Args) SubagentActions {
1568 return SubagentActions{
1569 Spawn: autoSubagentsToolAllowed(args),
1570 Stop: autoSubagentsStopToolAllowed(args),
1571 Resume: autoSubagentsResumeToolAllowed(args),
1572 Interrupt: autoSubagentsInterruptToolAllowed(args),
1573 }
1574}
1575
1576func autoSubagentsAnyToolAllowed(args Args) bool {
1577 return autoSubagentsToolAllowed(args) ||
1578 autoSubagentsStatusToolAllowed(args) ||
1579 autoSubagentsStopToolAllowed(args) ||
1580 autoSubagentsResumeToolAllowed(args) ||
1581 autoSubagentsInterruptToolAllowed(args)
1582}
1583
1584// subagentActionAllowed reports whether the launch-time tool policy grants one
1585// subagent action. A bare "subagent" entry grants every action; a
1586// "subagent:<action>" entry grants that action alone. Unrecognized entries are
1587// ignored, matching how every other --tools name is treated.
1588func subagentActionAllowed(args Args, action string) bool {
1589 if args.NoTools || args.PermissionSet != nil {
1590 return false
1591 }
1592 if args.ToolsSet && len(args.Tools) == 0 {
1593 return false
1594 }
1595 if len(args.Tools) == 0 {
1596 return true
1597 }
1598 for _, name := range args.Tools {
1599 if name == tools.SubagentToolName || name == tools.SubagentToolName+":"+action {
1600 return true
1601 }
1602 }
1603 return false
1604}
1605
1606var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "plan"}
1607
1608func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1609 var out []ToolSummary
1610 for _, name := range nativeToolSummaryOrder {
1611 if t, ok := reg[name]; ok {
1612 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1613 }
1614 }
1615 return out
1616}
1617
1618func firstNonEmpty(vals ...string) string {
1619 for _, v := range vals {
1620 if v != "" {
1621 return v
1622 }
1623 }
1624 return ""
1625}
1626
1627func kimiCodeHeaders() map[string]string {
1628 host, _ := os.Hostname()
1629 if host == "" {
1630 host = "unknown"
1631 }
1632 deviceID := ""
1633 if home, err := os.UserHomeDir(); err == nil {
1634 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1635 deviceID = strings.TrimSpace(string(b))
1636 }
1637 }
1638 if deviceID == "" {
1639 deviceID = "zut"
1640 }
1641 return map[string]string{
1642 "User-Agent": "KimiCLI/1.41.0",
1643 "X-Msh-Platform": "kimi_cli",
1644 "X-Msh-Version": "1.41.0",
1645 "X-Msh-Device-Name": host,
1646 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1647 "X-Msh-Os-Version": runtime.GOOS,
1648 "X-Msh-Device-Id": deviceID,
1649 }
1650}
1651
1652func envVarName(provider string) string {
1653 switch provider {
1654 case "openai", "openai-codex", "openai-responses":
1655 return "OPENAI"
1656 case "kimi":
1657 return "KIMI"
1658 case "deepseek":
1659 return "DEEPSEEK"
1660 case "google":
1661 return "GEMINI"
1662 case "ollama":
1663 return "OLLAMA"
1664 case "llama.cpp":
1665 return "LLAMA"
1666 case "moonshotai", "moonshotai-cn":
1667 return "MOONSHOT"
1668 case "groq":
1669 return "GROQ"
1670 case "xai":
1671 return "XAI"
1672 case "cerebras":
1673 return "CEREBRAS"
1674 case "together":
1675 return "TOGETHER"
1676 case "huggingface":
1677 return "HF"
1678 case "openrouter":
1679 return "OPENROUTER"
1680 case "mistral":
1681 return "MISTRAL"
1682 case "zai":
1683 return "ZAI"
1684 case "xiaomi":
1685 return "XIAOMI"
1686 case "xiaomi-token-plan-ams":
1687 return "XIAOMI_TOKEN_PLAN_AMS"
1688 case "xiaomi-token-plan-cn":
1689 return "XIAOMI_TOKEN_PLAN_CN"
1690 case "xiaomi-token-plan-sgp":
1691 return "XIAOMI_TOKEN_PLAN_SGP"
1692 case "minimax":
1693 return "MINIMAX"
1694 case "minimax-cn":
1695 return "MINIMAX_CN"
1696 case "fireworks":
1697 return "FIREWORKS"
1698 case "vercel-ai-gateway":
1699 return "AI_GATEWAY"
1700 case "opencode", "opencode-go":
1701 return "OPENCODE"
1702 case "github-copilot":
1703 return "COPILOT_GITHUB_TOKEN"
1704 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1705 return "CLOUDFLARE"
1706 case "amazon-bedrock":
1707 return "AWS"
1708 case "google-vertex":
1709 return "GOOGLE_CLOUD"
1710 case "azure-openai-responses":
1711 return "AZURE_OPENAI"
1712 default:
1713 return "ANTHROPIC"
1714 }
1715}