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