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