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