package agent import ( "context" "fmt" "os" "path/filepath" "runtime" "sort" "strings" "sync" "time" zutdocs "github.com/bnema/zut" "github.com/bnema/zut/packages/agent/lsp" "github.com/bnema/zut/packages/agent/skills" "github.com/bnema/zut/packages/agent/subagents" "github.com/bnema/zut/packages/agent/tools" "github.com/bnema/zut/packages/core" "github.com/bnema/zut/packages/provider" ) // ContextFile is an instruction file loaded into the system prompt. type ContextFile struct { Path string Content string } // Resolved is the effective configuration after merging CLI, config, defaults. type Resolved struct { Provider string Model string Credential string // api key or oauth access token AuthMethod string // "apikey" | "oauth" | "" (no credential yet) AccountID string // ChatGPT account id (for openai oauth), "" otherwise BaseURL string InsecureTLS bool CWD string Reasoning string Temperature *float32 FastMode bool WebSearchPolicy subagents.WebSearchPolicy ToolRegistry core.Registry ToolSummary []ToolSummary SystemPrompt string MaxSteps int DisableRepetitionGuard bool Sandbox *tools.Sandbox // ContextWindow and MaxOutput retain the effective model metadata, // including metadata synthesized for valid open-catalog local and routed // models that provider.FindModel cannot rediscover later. ContextWindow int // MaxOutput is the resolved model's maximum output-token budget // (from the catalog). Passed to the agent so each turn requests // the model's full output capacity instead of the provider's // conservative default (e.g. Bedrock's 4096, which truncates // long writes/edits with stopReason=length). MaxOutput int // modelCatalog is a runtime-owned snapshot for providers whose model // metadata is account- or endpoint-scoped. It prevents a later SDK // runtime from changing request shaping for an already-created runtime. modelCatalog []provider.Model modelCatalogAuthoritative bool // customProviderConfig snapshots user-provider transport settings so // client construction does not reread a mutable global registry. customProviderConfig *provider.CustomProviderConfig // credentialOptional marks a configured custom endpoint that may run // without any API credential (keyless). Credential stays empty then, so // clients send no Authorization header. credentialOptional bool // SkillTool is the on-demand skill loader registered with the // agent's tool registry, or nil if no SKILL.md files were // discovered. Exposed so the tui can list / preview skills. SkillTool *skills.Tool // ContextFiles records the AGENTS.md files appended to SystemPrompt, // in effective load order. Interactive mode uses this metadata to make // otherwise invisible startup context inspectable without adding fake // messages to the provider transcript. ContextFiles []ContextFile // skillsEnabled is true when discovery ran, even when it found no // skills. It is false only for an explicit clean-room disable. skillsEnabled bool // Bookkeeping for MergeExtensionTools. Captured at Resolve time // so the system prompt can be rebuilt later without re-running // resolve. systemAppend []string systemCustom string skillAddendum string toolDescriptions map[string]string } // HasCredential reports whether the resolved configuration can create a client. // Configured custom endpoints may intentionally omit an API credential. func (r Resolved) HasCredential() bool { return r.Credential != "" || r.credentialOptional } // ModelCatalogSnapshot returns the runtime-owned model metadata captured while // resolving. It is used by SDK clients whose provider catalog is scoped to a // credential or endpoint. func (r Resolved) ModelCatalogSnapshot() []provider.Model { return append([]provider.Model(nil), r.modelCatalog...) } // ModelCatalogIsAuthoritative reports whether the runtime snapshot came from // a successful provider catalog that is authoritative for model IDs. func (r Resolved) ModelCatalogIsAuthoritative() bool { return r.modelCatalogAuthoritative } // MergeExtensionTools folds every tool registered by an extension // into r's ToolRegistry and re-renders the system prompt's tool // summary so the model sees both built-in and extension tools. // // Idempotent: calling twice with the same manager state has no // effect on the second pass (existing names are preserved). Built-in // tools always win on conflict. func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { if mgr == nil { return } changed := false for _, info := range mgr.Tools() { // Web capability and other native names remain reserved even when their // policy excludes the current session. An extension must not turn a // normal CLI opt-out into a differently implemented capability. if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "glob" || info.Name == "ast" || info.Name == "python" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.PlanToolName || info.Name == tools.AskUserQuestionToolName { continue } if _, exists := r.ToolRegistry[info.Name]; exists { continue } r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info) changed = true } // A nil SkillTool can mean discovery ran and found nothing, so use the // explicit disable flag rather than the discovery result as the gate. if source, ok := mgr.(ExtensionSkillSource); ok && r.skillsEnabled { if bundled := source.Skills(); len(bundled) > 0 { merged := mergeExtensionSkills(r.SkillTool, bundled) if r.SkillTool == nil { r.SkillTool = skills.NewTool(merged) r.ToolRegistry[r.SkillTool.Name()] = r.SkillTool } else { r.SkillTool.SetSkills(merged) } append_ := append([]string(nil), r.systemAppend...) if r.skillAddendum != "" { filtered := append_[:0] for _, item := range append_ { if item != r.skillAddendum { filtered = append(filtered, item) } } append_ = filtered } r.skillAddendum = skills.SystemPromptAddendum(merged) if r.skillAddendum != "" { append_ = append(append_, r.skillAddendum) } r.systemAppend = append_ changed = true } } if !changed { return } // Re-render the system prompt with the merged tool and skill lists. r.ToolSummary = toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions) r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{ CWD: r.CWD, Tools: r.ToolSummary, Custom: r.systemCustom, Append: r.systemAppend, ZutDocsDir: filepath.Join(ZutHome(), "docs"), }) } func mergeExtensionSkills(tool *skills.Tool, bundled []*skills.Skill) []*skills.Skill { var existing []*skills.Skill if tool != nil { existing = tool.Skills() } merged := make([]*skills.Skill, 0, len(existing)+len(bundled)) seen := map[string]bool{} add := func(list []*skills.Skill) { for _, skill := range list { if skill == nil || skill.Name == "" || seen[skill.Name] { continue } seen[skill.Name] = true merged = append(merged, skill) } } var user, builtins []*skills.Skill for _, skill := range existing { if skill == nil || strings.HasPrefix(skill.Source, "extension ") { continue } if skill.Builtin { builtins = append(builtins, skill) } else { user = append(user, skill) } } add(user) add(bundled) add(builtins) return merged } // ExtensionToolSource is the slice of the extension manager that // MergeExtensionTools needs. Lives here as an interface so the // build package doesn't import packages/agent/extensions (which // imports core, which imports... avoid the cycle). type ExtensionToolSource interface { Tools() []ExtensionToolInfo NewExtensionTool(info ExtensionToolInfo) core.Tool } // ExtensionSkillSource exposes the optional bundled-skill surface without // forcing older ExtensionToolSource implementations to add it. type ExtensionSkillSource interface { Skills() []*skills.Skill } // ExtensionToolInfo mirrors extensions.ToolInfo so we can declare // ExtensionToolSource here without importing the extensions // package. The cli wires a tiny adapter to bridge them. type ExtensionToolInfo struct { Extension string Name string Description string Schema []byte Deferred bool Interactive bool } // toolSummariesFromRegistry rebuilds the system-prompt tool list // from a (possibly extended) registry, using cached descriptions for // the human-readable summary text. func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary { out := make([]ToolSummary, 0, len(reg)) seen := make(map[string]bool, len(nativeToolSummaryOrder)) appendSummary := func(name string) { t, ok := reg[name] if !ok { return } seen[name] = true if d, ok := t.(interface{ Deferred() bool }); ok && d.Deferred() { return } desc := t.Description() if d, ok := cached[name]; ok && d != "" { desc = d } out = append(out, ToolSummary{Name: name, Description: desc}) } for _, name := range nativeToolSummaryOrder { appendSummary(name) } remaining := make([]string, 0, len(reg)-len(seen)) for name := range reg { if !seen[name] { remaining = append(remaining, name) } } sort.Strings(remaining) for _, name := range remaining { appendSummary(name) } return out } const openCodeGoBootstrapModel = "kimi-k2.6" // defaultModelForProvider returns the model id zut prefers when the // caller didn't pick one. Mirrors the per-provider switch used at // multiple points in Resolve; centralised so the unknown-model // recovery path and the no-model-configured path can't drift. // // Returns the empty string for "ollama", which has no built-in // default — the caller is expected to special-case ollama and // error or use whatever the user passed. func defaultModelForProvider(prov string) string { switch prov { case provider.ProviderOpenAI: return "gpt-5" case provider.ProviderOpenAICodex: return "gpt-5.5" case provider.ProviderOpenAIResponses: return "gpt-5" case provider.ProviderKimi: return "kimi-for-coding" case provider.ProviderDeepSeek: return "deepseek-v4-pro" case provider.ProviderGoogle: return "gemini-2.5-pro" case provider.ProviderOllama, provider.ProviderLlamaCPP: return "" case provider.ProviderMoonshot, provider.ProviderMoonshotCN: return "kimi-k2.6" case provider.ProviderCerebras: return "qwen-3-235b-a22b-instruct-2507" case provider.ProviderGroq: return "llama-3.3-70b-versatile" case provider.ProviderXAI: return "grok-4.5" case provider.ProviderTogether: return "Qwen/Qwen3-Coder-480B-A35B-Instruct" case provider.ProviderHuggingFace: return "moonshotai/Kimi-K2-Instruct" case provider.ProviderOpenRouter: return "anthropic/claude-sonnet-4.5" case provider.ProviderMistral: return "mistral-large-latest" case provider.ProviderZAI: return "glm-4.7" case provider.ProviderXiaomi, provider.ProviderXiaomiTokenPlanAMS, provider.ProviderXiaomiTokenPlanCN, provider.ProviderXiaomiTokenPlanSGP: return "mimo-v2.5" case provider.ProviderMiniMax, provider.ProviderMiniMaxCN: return "MiniMax-M2.7" case provider.ProviderFireworks: return "accounts/fireworks/models/kimi-k2p6" case provider.ProviderVercelAIGateway: return "anthropic/claude-sonnet-4.5" case provider.ProviderOpenCode: return "claude-sonnet-4-5" case provider.ProviderOpenCodeGo: // OpenCode Go is synchronized at runtime, so prefer the first // currently served model and keep the former default only as a // bootstrap while the first refresh is still in flight. if models := provider.ModelsForProvider(prov); len(models) > 0 { return models[0].ID } return openCodeGoBootstrapModel case provider.ProviderAmazonBedrock: return "anthropic.claude-sonnet-4-5-20250929-v1:0" case provider.ProviderGoogleVertex: return "gemini-2.5-pro" case provider.ProviderAzureOpenAIResponses: return "gpt-5" case provider.ProviderGitHubCopilot: return "claude-sonnet-5" default: // Custom providers: pick the first model from the catalog for // that provider, or fall back to the global default. if models := provider.ModelsForProvider(prov); len(models) > 0 { return models[0].ID } return provider.DefaultModel.ID } } // knownProviders is the set of provider ids zut recognises. Used by // Resolve to validate args.Provider, by extension-callers, and by the // auto-fallback logic that picks any logged-in provider when the user's // preferred one has no credentials. var knownProviders = provider.BuiltinProviderIDs() func isKnownProvider(name string) bool { for _, p := range knownProviders { if p == name { return true } } _, ok := provider.CustomProviders()[name] return ok } // isBuiltinProvider reports whether name is in the hardcoded // knownProviders list (not a user-defined custom provider). func isBuiltinProvider(name string) bool { for _, p := range knownProviders { if p == name { return true } } return false } // providerAliases maps common short / alternate provider names to the // canonical id in knownProviders. Users (and other agents) reach for // "bedrock" or "vertex" far more naturally than the fully-qualified // "amazon-bedrock" / "google-vertex"; without this mapping an alias is // treated as an unknown provider and Resolve silently falls back to // anthropic, producing a misleading "no credential for anthropic" error // after the user explicitly picked, say, bedrock. var providerAliases = map[string]string{ "bedrock": "amazon-bedrock", "aws-bedrock": "amazon-bedrock", "amazon": "amazon-bedrock", "vertex": "google-vertex", "gcp-vertex": "google-vertex", "gemini": "google", "googleai": "google", "google-ai": "google", "azure": "azure-openai-responses", "azure-openai": "azure-openai-responses", "copilot": "github-copilot", "github": "github-copilot", "codex": "openai-codex", "moonshot": "moonshotai", "kimi-code": "kimi", "ai-gateway": "vercel-ai-gateway", "vercel": "vercel-ai-gateway", "cloudflare": "cloudflare-workers-ai", "workers-ai": "cloudflare-workers-ai", "hf": "huggingface", "llama": provider.LlamaCPPProviderID, "llamacpp": provider.LlamaCPPProviderID, } // canonicalProvider normalises a user-supplied provider name: trims // surrounding whitespace, lower-cases it, and resolves any known alias // to its canonical id. Unknown names are returned trimmed/lower-cased // and unchanged so the existing unknown-provider handling still runs. func canonicalProvider(name string) string { n := strings.ToLower(strings.TrimSpace(name)) if n == "" { return n } if canon, ok := providerAliases[n]; ok { return canon } return n } func findSubagentProfile(cwd, name string) (*subagents.Profile, error) { name = strings.TrimSpace(name) if name == "" { return nil, nil } home, _ := os.UserHomeDir() profiles, discoveryErrors := subagents.Discover(cwd, home) if len(discoveryErrors) > 0 { return nil, fmt.Errorf("discover subagent profiles: %d profile discovery error(s)", len(discoveryErrors)) } profile := subagents.Find(profiles, name) if profile == nil { return nil, fmt.Errorf("subagent profile %q not found (looked in ~/.agents/agents and configured ZUT_AGENT_PROFILES paths)", name) } return profile, nil } // resolveWebSearchPolicy applies capability precedence at the owning resolver // boundary. Permission, tool-list, and named-profile restrictions are ceilings // on every caller-provided policy. func resolveWebSearchPolicy(args Args, cfg Config, cfgErr error) subagents.WebSearchPolicy { if args.NoTools || args.PermissionSet != nil { return subagents.WebSearchDeny } if args.ToolsSet && !toolListContains(args.Tools, "web_search") { return subagents.WebSearchDeny } switch args.WebSearchPolicy { case subagents.WebSearchDeny: return subagents.WebSearchDeny case subagents.WebSearchAllow: return subagents.WebSearchAllow case subagents.WebSearchInherit: // Continue through normal-mode defaults below. Inherit is not a valid // propagated worker decision. default: return subagents.WebSearchDeny } if args.ToolsSet { return subagents.WebSearchAllow } if cfgErr != nil { return subagents.WebSearchDeny } if cfg.WebSearchEnabledForCLI() { return subagents.WebSearchAllow } return subagents.WebSearchDeny } func toolListContains(names []string, target string) bool { for _, name := range names { if name == target { return true } } return false } // webSearchAllowedForRegistry is kept separate from the resolver so direct // registry tests and callers that build a registry without persisted config // still get the normal default-on behavior. func webSearchAllowedForRegistry(args Args) bool { if args.NoTools || args.PermissionSet != nil { return false } if args.ToolsSet && !toolListContains(args.Tools, "web_search") { return false } switch args.WebSearchPolicy { case subagents.WebSearchAllow: return true case subagents.WebSearchDeny: return false default: return true } } func findModelForResolve(providerName, modelID string, scopedCatalog []provider.Model) (provider.Model, error) { if providerName == provider.ProviderOpenCodeGo && scopedCatalog != nil { for _, model := range scopedCatalog { if model.ID == modelID { return model, nil } } return provider.Model{}, fmt.Errorf("unknown model %q (provider=%q)", modelID, providerName) } return provider.FindModel(providerName, modelID) } func acceptsUnlistedModel(providerName string, args Args) bool { if providerName == provider.ProviderOpenCodeGo && args.modelCatalog != nil { return !args.modelCatalogAuthoritative } return provider.AcceptsUnlistedModels(providerName) } func defaultModelForResolve(providerName string, args Args) string { if providerName == provider.ProviderOpenCodeGo && args.modelCatalog != nil { if len(args.modelCatalog) > 0 { return args.modelCatalog[0].ID } return openCodeGoBootstrapModel } return defaultModelForProvider(providerName) } // Resolve merges args, config, and env into a Resolved set. // // Unlike the earlier version, Resolve NEVER returns an error for // missing credentials: the TUI can start without them and launch a // login flow. requireCred controls whether missing credentials are a // hard error (used by print/json modes). func Resolve(args Args, requireCred bool) (Resolved, error) { cfg, cfgErr := LoadConfig() profileSlot := cfg.applyActiveModelProfile() repairConfig := false // Programmatic callers historically supplied Tools without the parser's // provenance bit. Treat a non-empty list as explicit before a named profile // can contribute its defaults. if len(args.Tools) > 0 { args.ToolsSet = true } webSearchPolicy := resolveWebSearchPolicy(args, cfg, cfgErr) args.WebSearchPolicy = webSearchPolicy // User-requested provider (explicit > config > default). // Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock") // before validation so an alias is never mistaken for an unknown // provider and silently downgraded to anthropic. argProvider := canonicalProvider(args.Provider) provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic") if !isKnownProvider(provName) { // Unknown provider (maybe removed or renamed). Fall back to // the first provider that has credentials, or anthropic. // Custom providers (from models.json) are already accepted // by isKnownProvider, so we only reach here for truly unknown // names. provName = "anthropic" if CredentialAvailable("openai") { provName = "openai" } if CredentialAvailable("openai-codex") { provName = "openai-codex" } if CredentialAvailable("kimi") { provName = "kimi" } if CredentialAvailable("deepseek") { provName = "deepseek" } if CredentialAvailable("anthropic") { provName = "anthropic" } // Reset the saved config so this doesn't keep happening. cfg.Provider = provName cfg.Model = "" repairConfig = true } // An explicit auth:"none" custom provider ignores CLI, env, and stored // credentials entirely and never runs a stored api-key command. customCfg, customProvider := provider.CustomProviders()[provName] keylessCustom := customProvider && !isBuiltinProvider(provName) && customCfg.NoAuth var ( cred string method string accountID string credErr error // hasRealOllamaCred tracks whether ResolveCredentialFull returned a // genuine ollama credential (--api-key, OLLAMA_API_KEY, auth.json). // It drives the cloud-vs-local endpoint default below; the dummy // "ollama" credential never triggers the cloud default. hasRealOllamaCred bool ) if keylessCustom { // Explicit keyless configuration takes precedence over CLI, env, and // stored credentials. Never execute a stored API key command. cred, method, accountID, credErr = "", "", "", nil } else if provName == "ollama" { cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey) if credErr != nil { // Local ollama needs no key: fall back to the dummy // credential instead of failing resolution. cred, method, accountID, credErr = "ollama", "apikey", "", nil } else { hasRealOllamaCred = true } } else { cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey) } // Persist --api-key for custom providers so subsequent runs don't // need to pass it again. if !isBuiltinProvider(provName) && !keylessCustom && args.APIKey != "" { if store := AuthStoreFor(); store != nil { _ = store.SetAPIKey(provName, args.APIKey) } } // If the user did NOT explicitly pick a provider (neither via --provider // nor by saving one in config.json) and the default one has no // credentials, auto-fall-back to whichever provider is actually logged // in. That way running plain `zut` after `/login` (any provider) never // shows a "not logged in" banner. // // Critical: when the user HAS saved a provider in config.json (e.g. // "deepseek" with "deepseek-v4-flash"), that choice is respected even // if no credential is found — the tui will show the login dialog or // surface a clear credential error rather than silently switching to // a completely different provider and model. // autoFellBack tracks whether the provider was changed by the // auto-fallback loop (no explicit provider from CLI or config, // and the default had no credentials). When false, the provider // came from the user's explicit choice (--provider or config.json) // and must be respected, including any model id that may belong // to a different provider in the catalog — for example when using // a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash". var autoFellBack bool userPickedProvider := args.Provider != "" || cfg.Provider != "" if credErr != nil && !userPickedProvider && provName != "ollama" { autoFellBack = true // Scan every known provider (not a hardcoded subset) so any // env-based credential is discovered, e.g. an env-only // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE / // IAM keys) when no config.json pins the provider, such as after // pointing ZUT_HOME at a fresh home dir. Iteration order of // knownProviders defines fallback priority. Local providers without a // default model are skipped because selecting either one here would // fail before the user can choose a model. for _, other := range knownProviders { if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID { continue } if !CredentialAvailable(other) { continue } // An explicit launch key belongs to the initially selected provider; // automatic fallback must never reuse it for another provider. c, m, a, err := ResolveCredentialFull(other, "") provName = other cred, method, accountID, credErr = c, m, a, err break } } var customProviderConfig *provider.CustomProviderConfig if custom, ok := provider.CustomProviders()[provName]; ok { customProviderConfig = &custom } // Normalize model ids at the resolver boundary without mutating the // caller's Args value. Treat whitespace-only values as missing so a // configured fallback can still be selected. model := strings.TrimSpace(args.Model) if model == "" { model = strings.TrimSpace(cfg.Model) } if model == "" { if provName == "ollama" || provName == provider.LlamaCPPProviderID { return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName) } if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil { // Keep model selection inside the runtime snapshot instead of // consulting another runtime's mutable catalog. model = defaultModelForResolve(provName, args) } else { model = defaultModelForProvider(provName) } } // If the resolved model belongs to a different provider (e.g. config // says gpt-5 but we auto-fell back to anthropic), pick that provider's // default. This override only fires when the provider was auto-fallback- // changed, NOT when the user explicitly configured the pair — gateway/ // router providers (openrouter, vercel-ai-gateway, etc.) can serve // models from any provider in the catalog. if autoFellBack && provName != "ollama" { if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil { if _, err := findModelForResolve(provName, model, args.modelCatalog); err != nil { model = defaultModelForResolve(provName, args) } } else if m, err := provider.FindModel("", model); err == nil && m.Provider != provName { model = defaultModelForProvider(provName) } } if isDiscoverableCustomProvider(provName) { if _, ferr := provider.FindModel(provName, model); ferr != nil { // Restore discovery-enabled custom models after restart. Skipped // when the model is already known to avoid a probe per launch. // Scoped to the selected provider: other providers' endpoints and // api-key commands must not run for this launch. ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) _ = refreshCustomProviderModel(ctx, provName, apiKeyCommandExecute) cancel() } } resolvedModel, err := findModelForResolve(provName, model, args.modelCatalog) if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) { // Local providers are intentionally open-catalogue: any model id the // configured server understands is valid, even if it is not cached. resolvedModel = provider.Model{ Provider: provName, ID: model, DisplayName: model, ContextWindow: 128000, MaxOutput: 16384, BaseURL: args.BaseURL, Source: provName, } err = nil } // OpenCode Go publishes its authoritative model list at runtime. Keep // explicit model ids usable during the first refresh or while offline; // the provider request will be the final authority if no catalog data is // available yet. if err != nil && acceptsUnlistedModel(provName, args) { resolvedModel = provider.DynamicOpenCodeGoModel(provName, model, args.BaseURL) err = nil } // Custom providers are open-catalogue like ollama: any model id the // endpoint understands is valid. Use the provider-level base URL. if err != nil && customProviderConfig != nil { resolvedModel = provider.Model{ Provider: provName, ID: model, DisplayName: model, ContextWindow: 128000, MaxOutput: 16384, BaseURL: customProviderConfig.BaseURL, Source: "user", } err = nil } if customProviderConfig != nil && err == nil && resolvedModel.BaseURL == "" && customProviderConfig.BaseURL != "" { // Fall back to the provider-level base URL when the model does // not define its own endpoint. resolvedModel.BaseURL = customProviderConfig.BaseURL } if err != nil { // The model the user (or persisted config) asked for is no // longer in the active catalogue — they probably removed it // from their models.json or upgraded zut and the id changed. // Refusing to launch is the wrong move: it strands the user // with no way to even open the TUI and pick a new model. // Fall back to the provider's default, warn on stderr, and, // when the stale id came from the persisted config (not an // explicit --model flag), repair the config so the warning // doesn't repeat on every launch. // Gateway providers can accept route-qualified ids that are not in // zut's local catalog yet, for example OpenRouter's // "deepseek/deepseek-v4-flash". Preserve only route-qualified ids; // plain unknown values are likely typos and should still fall back. if isGatewayProvider(provName) && isGatewayRoutedModelID(model) { resolvedModel = provider.Model{ Provider: provName, ID: model, DisplayName: model, ContextWindow: 1000000, MaxOutput: 64000, BaseURL: "", Source: "gateway", } // Don't repair config — the routed model id may be valid upstream. } else { fallback := defaultModelForResolve(provName, args) fm, ferr := findModelForResolve(provName, fallback, args.modelCatalog) if ferr != nil { // Even the provider default is gone (catastrophic // catalogue trim). Last resort: any model on this // provider, then the global DefaultModel. A scoped // OpenCode Go runtime must stay inside its snapshot. candidates := provider.ModelsForProvider(provName) if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil { candidates = args.modelCatalog } if len(candidates) > 0 { fm = candidates[0] } else { fm = provider.DefaultModel } } fmt.Fprintf(os.Stderr, "zut: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n", model, fm.ID) if strings.TrimSpace(args.Model) == "" && strings.TrimSpace(cfg.Model) == model { cfg.Model = fm.ID repairConfig = true } resolvedModel = fm model = fm.ID } } if repairConfig { // Repair the source favorite as well as legacy defaults, but never // overwrite it because an explicit CLI override required fallback. if profileSlot > 0 && args.Provider == "" && args.Model == "" { cfg.Provider, cfg.Model = provName, model cfg.Reasoning = provider.ClampReasoningForModel(resolvedModel, cfg.Reasoning) cfg.QuickModelShortcuts[profileSlot-1] = QuickModelShortcut{ Provider: cfg.Provider, Model: cfg.Model, Reasoning: cfg.Reasoning, FastMode: cfg.FastMode != nil && *cfg.FastMode, } cfg.ActiveModelProfile = profileSlot } _ = SaveConfig(cfg) } // Discovered custom-provider models inherit the user's models.json // endpoint, so they count as explicit for --insecure scoping too. discoveredCustom := resolvedModel.Source == "live" && isDiscoverableCustomProvider(provName) explicitBaseURL := args.BaseURL != "" || ((resolvedModel.Source == "user" || discoveredCustom) && resolvedModel.BaseURL != "") // If the model defines a base URL (e.g. local ollama) and the // user didn't pass --base-url, use the model's URL. For ollama, // keep http://localhost:11434 as a fallback only after the model // metadata has had a chance to provide a custom baseUrl. if args.BaseURL == "" && resolvedModel.BaseURL != "" { args.BaseURL = resolvedModel.BaseURL } if args.BaseURL == "" && provName == "ollama" { // A real ollama credential with no explicit endpoint means // Ollama Cloud; without one, stay on the local server. // Explicit --base-url and model-level baseUrl (applied above) // both win over this default. if hasRealOllamaCred { args.BaseURL = provider.OllamaCloudBaseURL } else { args.BaseURL = "http://localhost:11434" } } if args.BaseURL == "" && provName == provider.LlamaCPPProviderID { managementURL, _, configErr := ResolveLlamaCPPConfig() if configErr != nil { return Resolved{}, configErr } var baseErr error args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL) if baseErr != nil { return Resolved{}, baseErr } } // Insecure TLS is intentionally scoped to explicit custom endpoints. // Built-in provider base URLs, auth calls, and model discovery keep normal // certificate verification even when --insecure is present. insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL // If the model has a base URL, credentials are optional (local // models like ollama don't need real API keys). credentialOptional := false if keylessCustom { if args.BaseURL == "" { return Resolved{}, fmt.Errorf("custom provider %q has auth none but no base URL", provName) } credentialOptional = true requireCred = false } else if resolvedModel.BaseURL != "" && credErr != nil { if customProviderConfig != nil && !isBuiltinProvider(provName) { // A custom endpoint may be configured for keyless access. Keep the // credential empty so no placeholder token is ever sent. credentialOptional = true } else { cred = "ollama" } credErr = nil requireCred = false } if credErr != nil && requireCred { return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zut` and /login", credErr, envVarName(provName)) } sandbox := tools.NewSandbox(args.CWD) if cfg.JailByDefault != nil && *cfg.JailByDefault { sandbox.Lock() } if args.PermissionSet != nil { sandbox.SetPermissions(args.PermissionSet) } lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(false) reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(false), cfg.LSPDiagnosticsOnEditEnabled(false)) maybeAddPythonTool(reg, args, args.CWD, sandbox, cfg.PythonInterpreter) addMCPTools(reg, args) docsDir, _ := zutdocs.EnsureInstalled(ZutHome()) // Skill discovery: scan project + global locations + built-in // skills shipped with the binary. If any are found, register // the on-demand `skill` loader tool plus a system-prompt // manifest so the model knows what's available. // // --no-skill bypasses the entire mechanism: no manifest in the // system prompt, no `skill` tool in the registry. Useful for a // clean-room run with zero extra context biasing the model. var ( discovered []*skills.Skill skillTool *skills.Tool skillAddendum string skillsEnabled bool ) if !args.NoSkill { skillsEnabled = true homeDir, _ := os.UserHomeDir() discovered, _ = skills.Discover(ZutHome(), args.CWD, homeDir, args.WithSkills) if len(discovered) > 0 { skillTool = skills.NewTool(discovered) reg[skillTool.Name()] = skillTool skillAddendum = skills.SystemPromptAddendum(discovered) } } _ = skillTool summaries := toolSummaries(reg, args) contextFiles := []ContextFile(nil) if !args.NoContextFiles { contextFiles = loadAgentsContext(args.CWD, ZutHome()) } append_ := []string(nil) append_ = append(append_, args.AppendSystemPrompt...) if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" { append_ = append(append_, agentsAddendum) } if skillAddendum != "" { append_ = append(append_, skillAddendum) } if mcpAddendum := mcpSystemAddendum(reg, args); mcpAddendum != "" { append_ = append(append_, mcpAddendum) } interactiveMode := args.Mode == "" || args.Mode == ModeInteractive primaryInteractive := interactiveMode && !args.ResidentChild primaryOrchestrator := args.Orchestrate && !args.ResidentChild if (primaryInteractive || primaryOrchestrator) && autoSubagentsToolAllowed(args) { homeDir, _ := os.UserHomeDir() profiles, _ := subagents.Discover(args.CWD, homeDir) if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" { append_ = append(append_, subagentsAddendum) } } if primaryOrchestrator { // Headless orchestration owns its strict prompt contract rather than // inheriting the interactive collaboration setting. append_ = append(append_, StrictOrchestratorSystemAddendumFor(subagentActionsAllowed(args))) } else if primaryInteractive && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled { append_ = append(append_, ProactiveSubagentsSystemAddendumFor(subagentActionsAllowed(args))) } else if primaryInteractive && autoSubagentsAnyToolAllowed(args) { append_ = append(append_, OnDemandSubagentsSystemAddendum) } // Custom system prompt resolution order: // 1. --system-prompt flag (highest priority; ad-hoc per run) // 2. $ZUT_HOME/SYSTEM.md (persistent user override) // 3. built-in default (defaultIdentity + defaultGuidelines) custom := args.SystemPrompt if custom == "" { custom = readUserSystemPrompt(ZutHome()) } sys := BuildSystemPrompt(SystemPromptOpts{ CWD: args.CWD, Tools: summaries, Custom: custom, Append: append_, ZutDocsDir: docsDir, }) reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning)) temperature := args.Temperature if temperature == nil { temperature = cfg.Temperature } fastMode := cfg.FastMode != nil && *cfg.FastMode if args.FastModeSet { fastMode = args.FastMode } max := args.MaxSteps // 0 = unlimited var modelCatalog []provider.Model if provName == provider.ProviderOpenCodeGo && args.modelCatalog != nil { modelCatalog = append([]provider.Model{}, args.modelCatalog...) found := false for _, catalogModel := range modelCatalog { if catalogModel.ID == model { found = true break } } if !found { modelCatalog = append(modelCatalog, resolvedModel) } } return Resolved{ Provider: provName, Model: model, Credential: cred, AuthMethod: method, AccountID: accountID, BaseURL: args.BaseURL, InsecureTLS: insecureTLS, CWD: args.CWD, Reasoning: reasoning, Temperature: temperature, FastMode: fastMode, WebSearchPolicy: webSearchPolicy, ToolRegistry: reg, ToolSummary: summaries, SystemPrompt: sys, MaxSteps: max, DisableRepetitionGuard: args.DisableRepetitionGuard, ContextWindow: resolvedModel.ContextWindow, MaxOutput: resolvedModel.MaxOutput, modelCatalog: modelCatalog, modelCatalogAuthoritative: args.modelCatalogAuthoritative, customProviderConfig: customProviderConfig, credentialOptional: credentialOptional, Sandbox: sandbox, SkillTool: skillTool, skillsEnabled: skillsEnabled, ContextFiles: contextFiles, systemAppend: append_, systemCustom: custom, skillAddendum: skillAddendum, toolDescriptions: descMapFromSummaries(summaries), }, nil } // readUserSystemPrompt looks for $ZUT_HOME/SYSTEM.md and returns its // trimmed contents, or "" when the file is missing / unreadable / // empty. Errors are intentionally swallowed: the file is optional, // and any failure to read it should fall back to the built-in // default system prompt rather than crash the run. func readUserSystemPrompt(zutHome string) string { if zutHome == "" { return "" } path := filepath.Join(zutHome, "SYSTEM.md") raw, err := os.ReadFile(path) if err != nil { return "" } return strings.TrimSpace(string(raw)) } // loadAgentsContext loads optional AGENTS.md instruction files. No // default file is created or required: zut only includes files that // already exist. Global instructions ($ZUT_HOME/AGENTS.md) come first, // followed by project instructions from the filesystem root down to cwd. func loadAgentsContext(cwd, zutHome string) []ContextFile { var files []ContextFile seen := map[string]bool{} add := func(path string) { if path == "" { return } abs, err := filepath.Abs(path) if err == nil { path = abs } if seen[path] { return } raw, err := os.ReadFile(path) if err != nil { return } content := strings.TrimSpace(string(raw)) if content == "" { return } seen[path] = true files = append(files, ContextFile{Path: path, Content: content}) } addFirstFromDir := func(dir string) { if dir == "" { return } for _, name := range []string{"AGENTS.md", "AGENTS.MD"} { path := filepath.Join(dir, name) if _, err := os.Stat(path); err == nil { add(path) return } } } addFirstFromDir(zutHome) if cwd != "" { abs, err := filepath.Abs(cwd) if err == nil { cwd = abs } var dirs []string for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) { dirs = append(dirs, dir) parent := filepath.Dir(dir) if parent == dir { break } } for i := len(dirs) - 1; i >= 0; i-- { addFirstFromDir(dirs[i]) } } return files } func formatAgentsContext(files []ContextFile) string { if len(files) == 0 { return "" } var sb strings.Builder 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") for _, f := range files { fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content) } return strings.TrimSpace(sb.String()) } // descMapFromSummaries indexes the human-readable descriptions for // the renderToolsSection rebuild path. func descMapFromSummaries(summaries []ToolSummary) map[string]string { out := make(map[string]string, len(summaries)) for _, s := range summaries { out[s.Name] = s.Description } return out } // NewClient returns a provider.Client for r, choosing the auth mode // based on r.AuthMethod. Panics if no credential is present; callers // must check HasCredential() first. func (r Resolved) NewClient() provider.Client { if !r.HasCredential() { panic("NewClient called without credential or keyless endpoint; check HasCredential first") } wrap := r.withHTTPClient switch r.Provider { case "ollama": return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, "")) case provider.LlamaCPPProviderID: credential := r.Credential if credential == "local" { credential = "" } return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, "")) case "kimi": // kimi-coding speaks anthropic-messages on api.kimi.com/coding. // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client. inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())) if r.AuthMethod == "oauth" { return r.wrapWithRefresh(inner) } return inner case "moonshotai": return wrap(provider.NewMoonshot(r.Credential, r.BaseURL)) case "moonshotai-cn": return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL)) case "deepseek": return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL)) case "openai": return provider.NewModelRouter("openai", wrap(provider.NewOpenAI(r.Credential, r.BaseURL)), map[string]provider.Client{ provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")), }) case "openai-codex": inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)) return r.wrapWithRefresh(inner) case "openai-responses": // Public OpenAI Responses API (api.openai.com/v1/responses) via // API key. Separate provider from `openai` (Chat Completions) and // from `openai-codex` (ChatGPT subscription OAuth). return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL)) case "google": return wrap(provider.NewGemini(r.Credential, r.BaseURL)) case "cerebras": return wrap(provider.NewCerebras(r.Credential, r.BaseURL)) case "groq": return wrap(provider.NewGroq(r.Credential, r.BaseURL)) case "xai": inner := r.newXAIClient(r.Credential) if r.AuthMethod == "oauth" { return r.wrapWithRefresh(inner) } return inner case "together": return wrap(provider.NewTogether(r.Credential, r.BaseURL)) case "huggingface": return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL)) case "openrouter": return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL)) case "zai": return wrap(provider.NewZAI(r.Credential, r.BaseURL)) case "xiaomi": return wrap(provider.NewXiaomi(r.Credential, r.BaseURL)) case "xiaomi-token-plan-ams": return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)) case "xiaomi-token-plan-cn": return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)) case "xiaomi-token-plan-sgp": return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)) case "opencode": return wrap(provider.NewOpenCode(r.Credential, r.BaseURL)) case provider.ProviderOpenCodeGo: return wrap(provider.NewOpenCodeGoWithModels(r.Credential, r.BaseURL, r.modelCatalog)) case "minimax": return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)) case "minimax-cn": return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)) case "fireworks": return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL)) case "vercel-ai-gateway": return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)) case "mistral": return wrap(provider.NewMistral(r.Credential, r.BaseURL)) case "amazon-bedrock": return wrap(provider.NewBedrock(r.Credential, r.BaseURL)) case "google-vertex": return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL)) case "azure-openai-responses": return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)) case "github-copilot": return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL)) case "cloudflare-workers-ai": return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)) case "cloudflare-ai-gateway": return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)) default: // Custom providers: choose wire format from the models.json api field. customConfig, ok := provider.CustomProviders()[r.Provider] if r.customProviderConfig != nil { customConfig, ok = *r.customProviderConfig, true } if ok { switch customConfig.API { case provider.APIResponses: return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider)) case "anthropic": return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL)) default: // "openai" return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, "")) } } if r.AuthMethod == "oauth" { inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL)) return r.wrapWithRefresh(inner) } return wrap(provider.NewAnthropic(r.Credential, r.BaseURL)) } } func (r Resolved) withHTTPClient(c provider.Client) provider.Client { if !r.InsecureTLS { return c } return provider.WithHTTPClient(c, provider.NewHTTPClient(true)) } func (r Resolved) newXAIClient(token string) provider.Client { return provider.NewModelRouter("xai", r.withHTTPClient(provider.NewXAI(token, r.BaseURL)), map[string]provider.Client{ provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")), }) } // wrapWithRefresh wraps an OAuth client so the access token is // refreshed automatically before each API call. Without this, long // sessions (hours) silently fail when the 1-hour token expires. func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client { provName := r.Provider tokenProvider := provName if provName == "openai-codex" { tokenProvider = "openai" } baseURL := r.BaseURL accountID := r.AccountID refreshFn := func(ctx context.Context) (string, error) { tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider)) if err != nil { return "", err } return tok.AccessToken, nil } factory := func(token string) provider.Client { switch provName { case "openai-codex": return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL)) case "kimi": // anthropic-messages on api.kimi.com/coding. return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())) case "xai": return r.newXAIClient(token) default: return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL)) } } return provider.NewRefreshingClient(inner, refreshFn, factory) } // UseSandbox replaces the sandbox pointer that every tool in r's // registry references. Used to keep the /jail state stable across // agent rebuilds (e.g. /login, /model switching providers). func (r *Resolved) UseSandbox(s *tools.Sandbox) { if s == nil || r == nil { return } if r.Sandbox != nil && r.Sandbox.Permissions != nil { s.Permissions = r.Sandbox.Permissions } r.Sandbox = s for name, t := range r.ToolRegistry { switch v := t.(type) { case *tools.ReadTool: v.Sandbox = s case *tools.WriteTool: v.Sandbox = s case *tools.EditTool: v.Sandbox = s case *tools.BashTool: v.Sandbox = s case *tools.PythonTool: v.Sandbox = s case *tools.WorktreeTool: if v.Create != nil { v.Create.Sandbox = s } if v.Manage != nil { v.Manage.Sandbox = s } case *tools.LSPTool: v.Sandbox = s case *tools.GrepTool: v.Sandbox = s case *tools.GlobTool: v.Sandbox = s case *tools.ASTTool: v.Sandbox = s } _ = name } } // NewAgent constructs a core.Agent from r. Requires a credential. func (r Resolved) NewAgent() *core.Agent { a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry) a.MaxSteps = r.MaxSteps a.DisableRepetitionGuard = r.DisableRepetitionGuard a.ContextWindow = r.ContextWindow a.MaxTokens = r.MaxOutput a.Reasoning = r.Reasoning a.Temperature = r.Temperature a.FastMode = r.FastMode if _, ok := r.ToolRegistry["worktree"].(*tools.WorktreeTool); ok { cwd := r.CWD a.HostContext = func(ctx context.Context) string { return tools.WorktreeInventoryContext(ctx, cwd) } } return a } func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled, diagnosticsOnWrite, diagnosticsOnEdit bool) core.Registry { if args.NoTools { return core.Registry{} } var manager *lsp.Manager if lspEnabled && lspManagerNeeded(args, diagnosticsOnWrite, diagnosticsOnEdit) { options := lsp.ManagerOptions{} if sandbox != nil { options.CheckWritePath = sandbox.CheckWritePath } manager = lsp.NewManagerWithOptions(options) } all := map[string]core.Tool{ "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, "worktree": &tools.WorktreeTool{ Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")}, Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox}, }, "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox}, "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox}, "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, "update_goal": &tools.UpdateGoalTool{}, "plan": &tools.PlanTool{}, "ask_user_question": &tools.AskUserQuestionTool{}, } if webSearchAllowedForRegistry(args) { for name, tool := range tools.NewWebTools() { all[name] = tool } } if manager != nil { lspTool := tools.NewLSPTool(cwd, manager) lspTool.Sandbox = sandbox lspTool.LSPDiagnostics = diagnosticsOnEdit all["lsp"] = lspTool } reg := core.Registry{} if len(args.Tools) == 0 { for _, t := range all { reg[t.Name()] = t } } else { for _, name := range args.Tools { if name == "web_search" { for webName, tool := range all { if tools.IsWebCapabilityName(webName) { reg[webName] = tool } } continue } if t, ok := all[name]; ok { reg[name] = t } } } if orchestratorExplorationToolsAllowed(args) { reg["read"] = all["read"] reg["grep"] = all["grep"] reg["glob"] = all["glob"] if astTool, ok := all["ast"].(*tools.ASTTool); ok { astTool.ReadOnly = true reg["ast"] = astTool } if args.PermissionSet == nil { for name, tool := range tools.NewWebTools() { reg[name] = tool } } } grepTool, hasGrep := reg["grep"].(*tools.GrepTool) astTool, hasAST := reg["ast"].(*tools.ASTTool) if hasGrep && hasAST { grepTool.ASTAvailable = sync.OnceValue(astTool.Available) } return reg } // pythonToolAllowed reports whether the python tool belongs in a registry // built for args. Python requires the absence of a packaged-agent // PermissionSet and either no explicit tool selection (the omitted --tools // default) or explicit inclusion of "python" in a nonempty list. An // explicitly empty selection enables no Python even though other built-in // tools retain their historical empty-list behavior. func pythonToolAllowed(args Args) bool { if args.NoTools || args.PermissionSet != nil { return false } if len(args.Tools) == 0 { return !args.ToolsSet } return toolListContains(args.Tools, "python") } // maybeAddPythonTool registers the python tool with its configured // interpreter. Kept outside buildToolRegistry so that helper's signature // stays unchanged; the interpreter stays available across rebuilds because // Resolve threads the persisted config value on every call. An unavailable // interpreter never blocks startup: resolution is lazy and failures surface // as actionable tool errors on invocation. func maybeAddPythonTool(reg core.Registry, args Args, cwd string, sandbox *tools.Sandbox, interpreter string) { if reg == nil || !pythonToolAllowed(args) { return } reg["python"] = &tools.PythonTool{CWD: cwd, Sandbox: sandbox, Interpreter: interpreter} } func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) bool { if len(args.Tools) == 0 { return true } for _, name := range args.Tools { switch name { case "lsp": return true case "write": if diagnosticsOnWrite { return true } case "edit", "ast": if diagnosticsOnEdit { return true } } } return false } func autoSubagentsToolAllowed(args Args) bool { return subagentActionAllowed(args, tools.SubagentActionSpawn) } // orchestratorExplorationToolsAllowed keeps the primary orchestrator's // read-only exploration tools available without enabling implementation tools. func orchestratorExplorationToolsAllowed(args Args) bool { return args.Orchestrate && !args.NoTools } func autoSubagentsStatusToolAllowed(args Args) bool { return subagentActionAllowed(args, tools.SubagentActionStatus) } func autoSubagentsStopToolAllowed(args Args) bool { return subagentActionAllowed(args, tools.SubagentActionStop) } func autoSubagentsResumeToolAllowed(args Args) bool { return subagentActionAllowed(args, tools.SubagentActionResume) } func autoSubagentsInterruptToolAllowed(args Args) bool { return subagentActionAllowed(args, tools.SubagentActionInterrupt) } // subagentActionsAllowed collects the launch-time subagent action grants. func subagentActionsAllowed(args Args) SubagentActions { return SubagentActions{ Spawn: autoSubagentsToolAllowed(args), Stop: autoSubagentsStopToolAllowed(args), Resume: autoSubagentsResumeToolAllowed(args), Interrupt: autoSubagentsInterruptToolAllowed(args), } } func autoSubagentsAnyToolAllowed(args Args) bool { return autoSubagentsToolAllowed(args) || autoSubagentsStatusToolAllowed(args) || autoSubagentsStopToolAllowed(args) || autoSubagentsResumeToolAllowed(args) || autoSubagentsInterruptToolAllowed(args) } // subagentActionAllowed reports whether the launch-time tool policy grants one // subagent action. A bare "subagent" entry grants every action; a // "subagent:" entry grants that action alone. Unrecognized entries are // ignored, matching how every other --tools name is treated. func subagentActionAllowed(args Args, action string) bool { if args.NoTools || args.PermissionSet != nil { return false } if args.ToolsSet && len(args.Tools) == 0 { return false } if len(args.Tools) == 0 { return true } for _, name := range args.Tools { if name == tools.SubagentToolName || name == tools.SubagentToolName+":"+action { return true } } return false } var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "plan", "ask_user_question"} func toolSummaries(reg core.Registry, args Args) []ToolSummary { var out []ToolSummary for _, name := range nativeToolSummaryOrder { if t, ok := reg[name]; ok { out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()}) } } return out } func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" } func kimiCodeHeaders() map[string]string { host, _ := os.Hostname() if host == "" { host = "unknown" } deviceID := "" if home, err := os.UserHomeDir(); err == nil { if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil { deviceID = strings.TrimSpace(string(b)) } } if deviceID == "" { deviceID = "zut" } return map[string]string{ "User-Agent": "KimiCLI/1.41.0", "X-Msh-Platform": "kimi_cli", "X-Msh-Version": "1.41.0", "X-Msh-Device-Name": host, "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH, "X-Msh-Os-Version": runtime.GOOS, "X-Msh-Device-Id": deviceID, } } func envVarName(provider string) string { switch provider { case "openai", "openai-codex", "openai-responses": return "OPENAI" case "kimi": return "KIMI" case "deepseek": return "DEEPSEEK" case "google": return "GEMINI" case "ollama": return "OLLAMA" case "llama.cpp": return "LLAMA" case "moonshotai", "moonshotai-cn": return "MOONSHOT" case "groq": return "GROQ" case "xai": return "XAI" case "cerebras": return "CEREBRAS" case "together": return "TOGETHER" case "huggingface": return "HF" case "openrouter": return "OPENROUTER" case "mistral": return "MISTRAL" case "zai": return "ZAI" case "xiaomi": return "XIAOMI" case "xiaomi-token-plan-ams": return "XIAOMI_TOKEN_PLAN_AMS" case "xiaomi-token-plan-cn": return "XIAOMI_TOKEN_PLAN_CN" case "xiaomi-token-plan-sgp": return "XIAOMI_TOKEN_PLAN_SGP" case "minimax": return "MINIMAX" case "minimax-cn": return "MINIMAX_CN" case "fireworks": return "FIREWORKS" case "vercel-ai-gateway": return "AI_GATEWAY" case "opencode", "opencode-go": return "OPENCODE" case "github-copilot": return "COPILOT_GITHUB_TOKEN" case "cloudflare-workers-ai", "cloudflare-ai-gateway": return "CLOUDFLARE" case "amazon-bedrock": return "AWS" case "google-vertex": return "GOOGLE_CLOUD" case "azure-openai-responses": return "AZURE_OPENAI" default: return "ANTHROPIC" } }