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