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 == "update_goal" {
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 compatibility locations)", 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 // Programmatic callers historically supplied Tools without the parser's
498 // provenance bit. Treat a non-empty list as explicit before a named profile
499 // can contribute its defaults.
500 if len(args.Tools) > 0 {
501 args.ToolsSet = true
502 }
503 webSearchPolicy := resolveWebSearchPolicy(args, cfg, cfgErr)
504 args.WebSearchPolicy = webSearchPolicy
505
506 // User-requested provider (explicit > config > default).
507 // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
508 // before validation so an alias is never mistaken for an unknown
509 // provider and silently downgraded to anthropic.
510 argProvider := canonicalProvider(args.Provider)
511 provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
512 if !isKnownProvider(provName) {
513 // Unknown provider (maybe removed or renamed). Fall back to
514 // the first provider that has credentials, or anthropic.
515 // Custom providers (from models.json) are already accepted
516 // by isKnownProvider, so we only reach here for truly unknown
517 // names.
518 provName = "anthropic"
519 if CredentialAvailable("openai") {
520 provName = "openai"
521 }
522 if CredentialAvailable("openai-codex") {
523 provName = "openai-codex"
524 }
525 if CredentialAvailable("kimi") {
526 provName = "kimi"
527 }
528 if CredentialAvailable("deepseek") {
529 provName = "deepseek"
530 }
531 if CredentialAvailable("anthropic") {
532 provName = "anthropic"
533 }
534 // Reset the saved config so this doesn't keep happening.
535 cfg.Provider = provName
536 cfg.Model = ""
537 _ = SaveConfig(cfg)
538 }
539
540 var (
541 cred string
542 method string
543 accountID string
544 credErr error
545 )
546 if provName == "ollama" {
547 cred = firstNonEmpty(args.APIKey, "ollama")
548 method = "apikey"
549 } else {
550 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
551 }
552
553 // Persist --api-key for custom providers so subsequent runs don't
554 // need to pass it again.
555 if !isBuiltinProvider(provName) && args.APIKey != "" {
556 if store := AuthStoreFor(); store != nil {
557 _ = store.SetAPIKey(provName, args.APIKey)
558 }
559 }
560
561 // If the user did NOT explicitly pick a provider (neither via --provider
562 // nor by saving one in config.json) and the default one has no
563 // credentials, auto-fall-back to whichever provider is actually logged
564 // in. That way running plain `zut` after `/login` (any provider) never
565 // shows a "not logged in" banner.
566 //
567 // Critical: when the user HAS saved a provider in config.json (e.g.
568 // "deepseek" with "deepseek-v4-flash"), that choice is respected even
569 // if no credential is found — the tui will show the login dialog or
570 // surface a clear credential error rather than silently switching to
571 // a completely different provider and model.
572 // autoFellBack tracks whether the provider was changed by the
573 // auto-fallback loop (no explicit provider from CLI or config,
574 // and the default had no credentials). When false, the provider
575 // came from the user's explicit choice (--provider or config.json)
576 // and must be respected, including any model id that may belong
577 // to a different provider in the catalog — for example when using
578 // a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash".
579 var autoFellBack bool
580 userPickedProvider := args.Provider != "" || cfg.Provider != ""
581 if credErr != nil && !userPickedProvider && provName != "ollama" {
582 autoFellBack = true
583 // Scan every known provider (not a hardcoded subset) so any
584 // env-based credential is discovered, e.g. an env-only
585 // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
586 // IAM keys) when no config.json pins the provider, such as after
587 // pointing ZUT_HOME at a fresh home dir. Iteration order of
588 // knownProviders defines fallback priority. Local providers without a
589 // default model are skipped because selecting either one here would
590 // fail before the user can choose a model.
591 for _, other := range knownProviders {
592 if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {
593 continue
594 }
595 if !CredentialAvailable(other) {
596 continue
597 }
598 // An explicit launch key belongs to the initially selected provider;
599 // automatic fallback must never reuse it for another provider.
600 c, m, a, err := ResolveCredentialFull(other, "")
601 provName = other
602 cred, method, accountID, credErr = c, m, a, err
603 break
604 }
605 }
606
607 model := firstNonEmpty(args.Model, cfg.Model)
608 if model == "" {
609 if provName == "ollama" || provName == provider.LlamaCPPProviderID {
610 return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
611 }
612 model = defaultModelForProvider(provName)
613 }
614 // If the resolved model belongs to a different provider (e.g. config
615 // says gpt-5 but we auto-fell back to anthropic), pick that provider's
616 // default. This override only fires when the provider was auto-fallback-
617 // changed, NOT when the user explicitly configured the pair — gateway/
618 // router providers (openrouter, vercel-ai-gateway, etc.) can serve
619 // models from any provider in the catalog.
620 if autoFellBack && provName != "ollama" {
621 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
622 model = defaultModelForProvider(provName)
623 }
624 }
625 resolvedModel, err := provider.FindModel(provName, model)
626 if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) {
627 // Local providers are intentionally open-catalogue: any model id the
628 // configured server understands is valid, even if it is not cached.
629 resolvedModel = provider.Model{
630 Provider: provName,
631 ID: model,
632 DisplayName: model,
633 ContextWindow: 128000,
634 MaxOutput: 16384,
635 BaseURL: args.BaseURL,
636 Source: provName,
637 }
638 err = nil
639 }
640 // Custom providers are open-catalogue like ollama: any model id the
641 // endpoint understands is valid. Use the provider-level base URL.
642 if err != nil {
643 if cfg, ok := provider.CustomProviders()[provName]; ok {
644 resolvedModel = provider.Model{
645 Provider: provName,
646 ID: model,
647 DisplayName: model,
648 ContextWindow: 128000,
649 MaxOutput: 16384,
650 BaseURL: cfg.BaseURL,
651 Source: "user",
652 }
653 err = nil
654 }
655 }
656 if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
657 // Fall back to the provider-level base URL when the model does
658 // not define its own endpoint.
659 resolvedModel.BaseURL = cfg.BaseURL
660 }
661 if err != nil {
662 // The model the user (or persisted config) asked for is no
663 // longer in the active catalogue — they probably removed it
664 // from their models.json or upgraded zut and the id changed.
665 // Refusing to launch is the wrong move: it strands the user
666 // with no way to even open the TUI and pick a new model.
667 // Fall back to the provider's default, warn on stderr, and,
668 // when the stale id came from the persisted config (not an
669 // explicit --model flag), repair the config so the warning
670 // doesn't repeat on every launch.
671
672 // Gateway providers can accept route-qualified ids that are not in
673 // zut's local catalog yet, for example OpenRouter's
674 // "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
675 // plain unknown values are likely typos and should still fall back.
676 if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
677 resolvedModel = provider.Model{
678 Provider: provName,
679 ID: model,
680 DisplayName: model,
681 ContextWindow: 1000000,
682 MaxOutput: 64000,
683 BaseURL: "",
684 Source: "gateway",
685 }
686 // Don't repair config — the routed model id may be valid upstream.
687 } else {
688 fallback := defaultModelForProvider(provName)
689 fm, ferr := provider.FindModel(provName, fallback)
690 if ferr != nil {
691 // Even the provider default is gone (catastrophic
692 // catalogue trim). Last resort: any model on this
693 // provider, then the global DefaultModel.
694 if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
695 fm = candidates[0]
696 } else {
697 fm = provider.DefaultModel
698 }
699 }
700 fmt.Fprintf(os.Stderr,
701 "zut: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
702 model, fm.ID)
703 if args.Model == "" && cfg.Model == model {
704 cfg.Model = fm.ID
705 _ = SaveConfig(cfg)
706 }
707 resolvedModel = fm
708 model = fm.ID
709 }
710 }
711
712 explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
713
714 // If the model defines a base URL (e.g. local ollama) and the
715 // user didn't pass --base-url, use the model's URL. For ollama,
716 // keep http://localhost:11434 as a fallback only after the model
717 // metadata has had a chance to provide a custom baseUrl.
718 if args.BaseURL == "" && resolvedModel.BaseURL != "" {
719 args.BaseURL = resolvedModel.BaseURL
720 }
721 if args.BaseURL == "" && provName == "ollama" {
722 args.BaseURL = "http://localhost:11434"
723 }
724 if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
725 managementURL, _, configErr := ResolveLlamaCPPConfig()
726 if configErr != nil {
727 return Resolved{}, configErr
728 }
729 var baseErr error
730 args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
731 if baseErr != nil {
732 return Resolved{}, baseErr
733 }
734 }
735
736 // Insecure TLS is intentionally scoped to explicit custom endpoints.
737 // Built-in provider base URLs, auth calls, and model discovery keep normal
738 // certificate verification even when --insecure is present.
739 insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
740
741 // If the model has a base URL, credentials are optional (local
742 // models like ollama don't need real API keys).
743 if resolvedModel.BaseURL != "" && credErr != nil {
744 cred = "ollama"
745 credErr = nil
746 requireCred = false
747 }
748
749 if credErr != nil && requireCred {
750 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zut` and /login",
751 credErr, envVarName(provName))
752 }
753
754 sandbox := tools.NewSandbox(args.CWD)
755 if cfg.JailByDefault != nil && *cfg.JailByDefault {
756 sandbox.Lock()
757 }
758 if args.PermissionSet != nil {
759 sandbox.SetPermissions(args.PermissionSet)
760 }
761 lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false)
762 reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(false), cfg.LSPDiagnosticsOnEditEnabled(false))
763
764 docsDir, _ := zutdocs.EnsureInstalled(ZutHome())
765
766 // Skill discovery: scan project + global locations + built-in
767 // skills shipped with the binary. If any are found, register
768 // the on-demand `skill` loader tool plus a system-prompt
769 // manifest so the model knows what's available.
770 //
771 // --no-skill bypasses the entire mechanism: no manifest in the
772 // system prompt, no `skill` tool in the registry. Useful for a
773 // clean-room run with zero extra context biasing the model.
774 var (
775 discovered []*skills.Skill
776 skillTool *skills.Tool
777 skillAddendum string
778 skillsEnabled bool
779 )
780 if !args.NoSkill {
781 skillsEnabled = true
782 homeDir, _ := os.UserHomeDir()
783 discovered, _ = skills.Discover(ZutHome(), args.CWD, homeDir, args.WithSkills)
784 if len(discovered) > 0 {
785 skillTool = skills.NewTool(discovered)
786 reg[skillTool.Name()] = skillTool
787 skillAddendum = skills.SystemPromptAddendum(discovered)
788 }
789 }
790 _ = skillTool
791
792 summaries := toolSummaries(reg, args)
793
794 contextFiles := []ContextFile(nil)
795 if !args.NoContextFiles {
796 contextFiles = loadAgentsContext(args.CWD, ZutHome())
797 }
798 append_ := []string(nil)
799 if cfg.PonytailModeEnabled() {
800 if ponytailAddendum := PonytailSystemAddendum(); ponytailAddendum != "" {
801 append_ = append(append_, ponytailAddendum)
802 }
803 }
804 append_ = append(append_, args.AppendSystemPrompt...)
805 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
806 append_ = append(append_, agentsAddendum)
807 }
808 if skillAddendum != "" {
809 append_ = append(append_, skillAddendum)
810 }
811 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
812 primaryInteractive := interactiveMode
813 primaryOrchestrator := args.Orchestrate
814 if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) {
815 homeDir, _ := os.UserHomeDir()
816 profiles, _ := subagents.Discover(args.CWD, homeDir)
817 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
818 append_ = append(append_, subagentsAddendum)
819 }
820 }
821 if primaryOrchestrator {
822 // Headless orchestration owns its strict prompt contract rather than
823 // inheriting the interactive setting.
824 append_ = append(append_, AutoSubagentsSystemAddendumFor(
825 autoSubagentsToolAllowed(args),
826 autoSubagentsStopToolAllowed(args),
827 autoSubagentsResumeToolAllowed(args),
828 ))
829 } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
830 append_ = append(append_, AutoSubagentsSystemAddendumFor(
831 autoSubagentsToolAllowed(args),
832 autoSubagentsStopToolAllowed(args),
833 autoSubagentsResumeToolAllowed(args),
834 ))
835 } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) {
836 append_ = append(append_, OnDemandSubagentsSystemAddendum)
837 }
838 // Custom system prompt resolution order:
839 // 1. --system-prompt flag (highest priority; ad-hoc per run)
840 // 2. $ZUT_HOME/SYSTEM.md (persistent user override)
841 // 3. built-in default (defaultIdentity + defaultGuidelines)
842 custom := args.SystemPrompt
843 if custom == "" {
844 custom = readUserSystemPrompt(ZutHome())
845 }
846
847 sys := BuildSystemPrompt(SystemPromptOpts{
848 CWD: args.CWD,
849 Tools: summaries,
850 Custom: custom,
851 Append: append_,
852 ZutDocsDir: docsDir,
853 })
854
855 reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
856 temperature := args.Temperature
857 if temperature == nil {
858 temperature = cfg.Temperature
859 }
860 fastMode := cfg.FastMode != nil && *cfg.FastMode
861 if args.FastModeSet {
862 fastMode = args.FastMode
863 }
864 max := args.MaxSteps // 0 = unlimited
865
866 return Resolved{
867 Provider: provName,
868 Model: model,
869 Credential: cred,
870 AuthMethod: method,
871 AccountID: accountID,
872 BaseURL: args.BaseURL,
873 InsecureTLS: insecureTLS,
874 CWD: args.CWD,
875 Reasoning: reasoning,
876 Temperature: temperature,
877 FastMode: fastMode,
878 WebSearchPolicy: webSearchPolicy,
879 ToolRegistry: reg,
880 ToolSummary: summaries,
881 SystemPrompt: sys,
882 MaxSteps: max,
883 ContextWindow: resolvedModel.ContextWindow,
884 MaxOutput: resolvedModel.MaxOutput,
885 Sandbox: sandbox,
886 SkillTool: skillTool,
887 skillsEnabled: skillsEnabled,
888 ContextFiles: contextFiles,
889 systemAppend: append_,
890 systemCustom: custom,
891 skillAddendum: skillAddendum,
892 toolDescriptions: descMapFromSummaries(summaries),
893 }, nil
894}
895
896// readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its
897// trimmed contents, or "" when the file is missing / unreadable /
898// empty. Errors are intentionally swallowed: the file is optional,
899// and any failure to read it should fall back to the built-in
900// default system prompt rather than crash the run.
901func readUserSystemPrompt(zutHome string) string {
902 if zutHome == "" {
903 return ""
904 }
905 path := filepath.Join(zutHome, "SYSTEM.md")
906 raw, err := os.ReadFile(path)
907 if err != nil {
908 return ""
909 }
910 return strings.TrimSpace(string(raw))
911}
912
913// loadAgentsContext loads optional AGENTS.md instruction files. No
914// default file is created or required: zut only includes files that
915// already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first,
916// followed by project instructions from the filesystem root down to cwd.
917func loadAgentsContext(cwd, zutHome string) []ContextFile {
918 var files []ContextFile
919 seen := map[string]bool{}
920 add := func(path string) {
921 if path == "" {
922 return
923 }
924 abs, err := filepath.Abs(path)
925 if err == nil {
926 path = abs
927 }
928 if seen[path] {
929 return
930 }
931 raw, err := os.ReadFile(path)
932 if err != nil {
933 return
934 }
935 content := strings.TrimSpace(string(raw))
936 if content == "" {
937 return
938 }
939 seen[path] = true
940 files = append(files, ContextFile{Path: path, Content: content})
941 }
942 addFirstFromDir := func(dir string) {
943 if dir == "" {
944 return
945 }
946 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
947 path := filepath.Join(dir, name)
948 if _, err := os.Stat(path); err == nil {
949 add(path)
950 return
951 }
952 }
953 }
954
955 addFirstFromDir(zutHome)
956
957 if cwd != "" {
958 abs, err := filepath.Abs(cwd)
959 if err == nil {
960 cwd = abs
961 }
962 var dirs []string
963 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
964 dirs = append(dirs, dir)
965 parent := filepath.Dir(dir)
966 if parent == dir {
967 break
968 }
969 }
970 for i := len(dirs) - 1; i >= 0; i-- {
971 addFirstFromDir(dirs[i])
972 }
973 }
974
975 return files
976}
977
978func formatAgentsContext(files []ContextFile) string {
979 if len(files) == 0 {
980 return ""
981 }
982 var sb strings.Builder
983 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")
984 for _, f := range files {
985 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
986 }
987 return strings.TrimSpace(sb.String())
988}
989
990// descMapFromSummaries indexes the human-readable descriptions for
991// the renderToolsSection rebuild path.
992func descMapFromSummaries(summaries []ToolSummary) map[string]string {
993 out := make(map[string]string, len(summaries))
994 for _, s := range summaries {
995 out[s.Name] = s.Description
996 }
997 return out
998}
999
1000// NewClient returns a provider.Client for r, choosing the auth mode
1001// based on r.AuthMethod. Panics if no credential is present; callers
1002// must check HasCredential() first.
1003func (r Resolved) NewClient() provider.Client {
1004 if !r.HasCredential() {
1005 panic("NewClient called without credential; check HasCredential first")
1006 }
1007 wrap := r.withHTTPClient
1008 switch r.Provider {
1009 case "ollama":
1010 return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
1011 case provider.LlamaCPPProviderID:
1012 credential := r.Credential
1013 if credential == "local" {
1014 credential = ""
1015 }
1016 return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
1017 case "kimi":
1018 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
1019 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
1020 inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
1021 if r.AuthMethod == "oauth" {
1022 return r.wrapWithRefresh(inner)
1023 }
1024 return inner
1025 case "moonshotai":
1026 return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
1027 case "moonshotai-cn":
1028 return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
1029 case "deepseek":
1030 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
1031 case "openai":
1032 return provider.NewModelRouter("openai",
1033 wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
1034 map[string]provider.Client{
1035 provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
1036 })
1037 case "openai-codex":
1038 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
1039 return r.wrapWithRefresh(inner)
1040 case "openai-responses":
1041 // Public OpenAI Responses API (api.openai.com/v1/responses) via
1042 // API key. Separate provider from `openai` (Chat Completions) and
1043 // from `openai-codex` (ChatGPT subscription OAuth).
1044 return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
1045 case "google":
1046 return wrap(provider.NewGemini(r.Credential, r.BaseURL))
1047 case "cerebras":
1048 return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
1049 case "groq":
1050 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
1051 case "xai":
1052 inner := r.newXAIClient(r.Credential)
1053 if r.AuthMethod == "oauth" {
1054 return r.wrapWithRefresh(inner)
1055 }
1056 return inner
1057 case "together":
1058 return wrap(provider.NewTogether(r.Credential, r.BaseURL))
1059 case "huggingface":
1060 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
1061 case "openrouter":
1062 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
1063 case "zai":
1064 return wrap(provider.NewZAI(r.Credential, r.BaseURL))
1065 case "xiaomi":
1066 return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
1067 case "xiaomi-token-plan-ams":
1068 return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
1069 case "xiaomi-token-plan-cn":
1070 return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
1071 case "xiaomi-token-plan-sgp":
1072 return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
1073 case "opencode":
1074 return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
1075 case "opencode-go":
1076 return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
1077 case "minimax":
1078 return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
1079 case "minimax-cn":
1080 return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
1081 case "fireworks":
1082 return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
1083 case "vercel-ai-gateway":
1084 return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
1085 case "mistral":
1086 return wrap(provider.NewMistral(r.Credential, r.BaseURL))
1087 case "amazon-bedrock":
1088 return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
1089 case "google-vertex":
1090 return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
1091 case "azure-openai-responses":
1092 return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
1093 case "github-copilot":
1094 return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
1095 case "cloudflare-workers-ai":
1096 return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
1097 case "cloudflare-ai-gateway":
1098 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
1099 default:
1100 // Custom providers: choose wire format from the models.json api field.
1101 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
1102 switch cfg.API {
1103 case provider.APIResponses:
1104 return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
1105 case "anthropic":
1106 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
1107 default: // "openai"
1108 return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
1109 }
1110 }
1111 if r.AuthMethod == "oauth" {
1112 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
1113 return r.wrapWithRefresh(inner)
1114 }
1115 return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
1116 }
1117}
1118
1119func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
1120 if !r.InsecureTLS {
1121 return c
1122 }
1123 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
1124}
1125
1126func (r Resolved) newXAIClient(token string) provider.Client {
1127 return provider.NewModelRouter("xai",
1128 r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
1129 map[string]provider.Client{
1130 provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
1131 })
1132}
1133
1134// wrapWithRefresh wraps an OAuth client so the access token is
1135// refreshed automatically before each API call. Without this, long
1136// sessions (hours) silently fail when the 1-hour token expires.
1137func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
1138 provName := r.Provider
1139 tokenProvider := provName
1140 if provName == "openai-codex" {
1141 tokenProvider = "openai"
1142 }
1143 baseURL := r.BaseURL
1144 accountID := r.AccountID
1145
1146 refreshFn := func(ctx context.Context) (string, error) {
1147 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
1148 if err != nil {
1149 return "", err
1150 }
1151 return tok.AccessToken, nil
1152 }
1153
1154 factory := func(token string) provider.Client {
1155 switch provName {
1156 case "openai-codex":
1157 return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1158 case "kimi":
1159 // anthropic-messages on api.kimi.com/coding.
1160 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1161 case "xai":
1162 return r.newXAIClient(token)
1163 default:
1164 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1165 }
1166 }
1167
1168 return provider.NewRefreshingClient(inner, refreshFn, factory)
1169}
1170
1171// UseSandbox replaces the sandbox pointer that every tool in r's
1172// registry references. Used to keep the /jail state stable across
1173// agent rebuilds (e.g. /login, /model switching providers).
1174func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1175 if s == nil || r == nil {
1176 return
1177 }
1178 if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1179 s.Permissions = r.Sandbox.Permissions
1180 }
1181 r.Sandbox = s
1182 for name, t := range r.ToolRegistry {
1183 switch v := t.(type) {
1184 case *tools.ReadTool:
1185 v.Sandbox = s
1186 case *tools.WriteTool:
1187 v.Sandbox = s
1188 case *tools.EditTool:
1189 v.Sandbox = s
1190 case *tools.BashTool:
1191 v.Sandbox = s
1192 case *tools.CreateWorktreeTool:
1193 v.Sandbox = s
1194 case *tools.LSPTool:
1195 v.Sandbox = s
1196 case *tools.GrepTool:
1197 v.Sandbox = s
1198 }
1199 _ = name
1200 }
1201}
1202
1203// NewAgent constructs a core.Agent from r. Requires a credential.
1204func (r Resolved) NewAgent() *core.Agent {
1205 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1206 a.MaxSteps = r.MaxSteps
1207 a.ContextWindow = r.ContextWindow
1208 a.MaxTokens = r.MaxOutput
1209 a.Reasoning = r.Reasoning
1210 a.Temperature = r.Temperature
1211 a.FastMode = r.FastMode
1212 return a
1213}
1214
1215func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry {
1216 if args.NoTools {
1217 return core.Registry{}
1218 }
1219 var manager *lsp.Manager
1220 if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) {
1221 options := lsp.ManagerOptions{}
1222 if sandbox != nil {
1223 options.CheckWritePath = sandbox.CheckWritePath
1224 }
1225 manager = lsp.NewManagerWithOptions(options)
1226 }
1227 all := map[string]core.Tool{
1228 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1229 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1230 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1231 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1232 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1233 "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1234 "update_goal": &tools.UpdateGoalTool{},
1235 }
1236 if webSearchAllowedForRegistry(args) {
1237 for name, tool := range tools.NewWebTools() {
1238 all[name] = tool
1239 }
1240 }
1241 if manager != nil {
1242 lspTool := tools.NewLSPTool(cwd, manager)
1243 lspTool.Sandbox = sandbox
1244 all["lsp"] = lspTool
1245 }
1246 reg := core.Registry{}
1247 if len(args.Tools) == 0 {
1248 for _, t := range all {
1249 reg[t.Name()] = t
1250 }
1251 } else {
1252 for _, name := range args.Tools {
1253 if name == "web_search" {
1254 for webName, tool := range all {
1255 if tools.IsWebCapabilityName(webName) {
1256 reg[webName] = tool
1257 }
1258 }
1259 continue
1260 }
1261 if t, ok := all[name]; ok {
1262 reg[name] = t
1263 }
1264 }
1265 }
1266 if orchestratorExplorationToolsAllowed(args) {
1267 reg["read"] = all["read"]
1268 reg["grep"] = all["grep"]
1269 if args.PermissionSet == nil {
1270 for name, tool := range tools.NewWebTools() {
1271 reg[name] = tool
1272 }
1273 }
1274 }
1275 return reg
1276}
1277
1278func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool {
1279 if len(args.Tools) == 0 {
1280 return true
1281 }
1282 for _, name := range args.Tools {
1283 switch name {
1284 case "lsp":
1285 return true
1286 case "write":
1287 if diagnosticsOnWrite {
1288 return true
1289 }
1290 case "edit":
1291 if diagnosticsOnEdit {
1292 return true
1293 }
1294 }
1295 }
1296 return false
1297}
1298
1299func autoSubagentsToolAllowed(args Args) bool {
1300 return autoSubagentsToolAllowedFor(args, "subagent_spawn")
1301}
1302
1303// orchestratorExplorationToolsAllowed keeps the primary orchestrator's
1304// read-only exploration tools available without enabling implementation tools.
1305func orchestratorExplorationToolsAllowed(args Args) bool {
1306 return args.Orchestrate && !args.NoTools
1307}
1308
1309func autoSubagentsStatusToolAllowed(args Args) bool {
1310 return autoSubagentsToolAllowedFor(args, "subagent_status")
1311}
1312
1313func autoSubagentsStopToolAllowed(args Args) bool {
1314 return autoSubagentsToolAllowedFor(args, "subagent_stop")
1315}
1316
1317func autoSubagentsResumeToolAllowed(args Args) bool {
1318 return autoSubagentsToolAllowedFor(args, "subagent_resume")
1319}
1320
1321func autoSubagentsAnyToolAllowed(args Args) bool {
1322 return autoSubagentsToolAllowed(args) ||
1323 autoSubagentsStatusToolAllowed(args) ||
1324 autoSubagentsStopToolAllowed(args) ||
1325 autoSubagentsResumeToolAllowed(args)
1326}
1327
1328func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1329 if args.NoTools || args.PermissionSet != nil {
1330 return false
1331 }
1332 if args.ToolsSet && len(args.Tools) == 0 {
1333 return false
1334 }
1335 if len(args.Tools) == 0 {
1336 return true
1337 }
1338 for _, name := range args.Tools {
1339 if name == toolName {
1340 return true
1341 }
1342 }
1343 return false
1344}
1345
1346var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal"}
1347
1348func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1349 var out []ToolSummary
1350 for _, name := range nativeToolSummaryOrder {
1351 if t, ok := reg[name]; ok {
1352 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1353 }
1354 }
1355 return out
1356}
1357
1358func firstNonEmpty(vals ...string) string {
1359 for _, v := range vals {
1360 if v != "" {
1361 return v
1362 }
1363 }
1364 return ""
1365}
1366
1367func kimiCodeHeaders() map[string]string {
1368 host, _ := os.Hostname()
1369 if host == "" {
1370 host = "unknown"
1371 }
1372 deviceID := ""
1373 if home, err := os.UserHomeDir(); err == nil {
1374 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1375 deviceID = strings.TrimSpace(string(b))
1376 }
1377 }
1378 if deviceID == "" {
1379 deviceID = "zut"
1380 }
1381 return map[string]string{
1382 "User-Agent": "KimiCLI/1.41.0",
1383 "X-Msh-Platform": "kimi_cli",
1384 "X-Msh-Version": "1.41.0",
1385 "X-Msh-Device-Name": host,
1386 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1387 "X-Msh-Os-Version": runtime.GOOS,
1388 "X-Msh-Device-Id": deviceID,
1389 }
1390}
1391
1392func envVarName(provider string) string {
1393 switch provider {
1394 case "openai", "openai-codex", "openai-responses":
1395 return "OPENAI"
1396 case "kimi":
1397 return "KIMI"
1398 case "deepseek":
1399 return "DEEPSEEK"
1400 case "google":
1401 return "GEMINI"
1402 case "ollama":
1403 return "OLLAMA"
1404 case "llama.cpp":
1405 return "LLAMA"
1406 case "moonshotai", "moonshotai-cn":
1407 return "MOONSHOT"
1408 case "groq":
1409 return "GROQ"
1410 case "xai":
1411 return "XAI"
1412 case "cerebras":
1413 return "CEREBRAS"
1414 case "together":
1415 return "TOGETHER"
1416 case "huggingface":
1417 return "HF"
1418 case "openrouter":
1419 return "OPENROUTER"
1420 case "mistral":
1421 return "MISTRAL"
1422 case "zai":
1423 return "ZAI"
1424 case "xiaomi":
1425 return "XIAOMI"
1426 case "xiaomi-token-plan-ams":
1427 return "XIAOMI_TOKEN_PLAN_AMS"
1428 case "xiaomi-token-plan-cn":
1429 return "XIAOMI_TOKEN_PLAN_CN"
1430 case "xiaomi-token-plan-sgp":
1431 return "XIAOMI_TOKEN_PLAN_SGP"
1432 case "minimax":
1433 return "MINIMAX"
1434 case "minimax-cn":
1435 return "MINIMAX_CN"
1436 case "fireworks":
1437 return "FIREWORKS"
1438 case "vercel-ai-gateway":
1439 return "AI_GATEWAY"
1440 case "opencode", "opencode-go":
1441 return "OPENCODE"
1442 case "github-copilot":
1443 return "COPILOT_GITHUB_TOKEN"
1444 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1445 return "CLOUDFLARE"
1446 case "amazon-bedrock":
1447 return "AWS"
1448 case "google-vertex":
1449 return "GOOGLE_CLOUD"
1450 case "azure-openai-responses":
1451 return "AZURE_OPENAI"
1452 default:
1453 return "ANTHROPIC"
1454 }
1455}