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