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 resolvedModel, err := provider.FindModel(provName, model)
450 if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID) {
451 // Local providers are intentionally open-catalogue: any model id the
452 // configured server understands is valid, even if it is not cached.
453 resolvedModel = provider.Model{
454 Provider: provName,
455 ID: model,
456 DisplayName: model,
457 ContextWindow: 128000,
458 MaxOutput: 16384,
459 BaseURL: args.BaseURL,
460 Source: provName,
461 }
462 if provName == provider.LMStudioProviderID {
463 resolvedModel.ContextWindow = 32768
464 resolvedModel.MaxOutput = 4096
465 }
466 err = nil
467 }
468 // Custom providers are open-catalogue like ollama: any model id the
469 // endpoint understands is valid. Use the provider-level base URL.
470 if err != nil {
471 if cfg, ok := provider.CustomProviders()[provName]; ok {
472 resolvedModel = provider.Model{
473 Provider: provName,
474 ID: model,
475 DisplayName: model,
476 BaseURL: cfg.BaseURL,
477 Source: "user",
478 }
479 err = nil
480 }
481 }
482 if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
483 // Fall back to the provider-level base URL when the model does
484 // not define its own endpoint.
485 resolvedModel.BaseURL = cfg.BaseURL
486 }
487 if err != nil {
488 // The model the user (or persisted config) asked for is no
489 // longer in the active catalogue — they probably removed it
490 // from their models.json or upgraded zot and the id changed.
491 // Refusing to launch is the wrong move: it strands the user
492 // with no way to even open the TUI and pick a new model.
493 // Fall back to the provider's default, warn on stderr, and,
494 // when the stale id came from the persisted config (not an
495 // explicit --model flag), repair the config so the warning
496 // doesn't repeat on every launch.
497
498 // Gateway providers can accept route-qualified ids that are not in
499 // zot's local catalog yet, for example OpenRouter's
500 // "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
501 // plain unknown values are likely typos and should still fall back.
502 if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
503 resolvedModel = provider.Model{
504 Provider: provName,
505 ID: model,
506 DisplayName: model,
507 ContextWindow: 1000000,
508 MaxOutput: 64000,
509 BaseURL: "",
510 Source: "gateway",
511 }
512 err = nil
513 // Don't repair config — the routed model id may be valid upstream.
514 } else {
515 fallback := defaultModelForProvider(provName)
516 fm, ferr := provider.FindModel(provName, fallback)
517 if ferr != nil {
518 // Even the provider default is gone (catastrophic
519 // catalogue trim). Last resort: any model on this
520 // provider, then the global DefaultModel.
521 if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
522 fm = candidates[0]
523 ferr = nil
524 } else {
525 fm = provider.DefaultModel
526 ferr = nil
527 }
528 }
529 fmt.Fprintf(os.Stderr,
530 "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
531 model, fm.ID)
532 if args.Model == "" && cfg.Model == model {
533 cfg.Model = fm.ID
534 _ = SaveConfig(cfg)
535 }
536 resolvedModel = fm
537 model = fm.ID
538 }
539 }
540
541 explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
542
543 // If the model defines a base URL (e.g. local ollama) and the
544 // user didn't pass --base-url, use the model's URL. For ollama,
545 // keep http://localhost:11434 as a fallback only after the model
546 // metadata has had a chance to provide a custom baseUrl.
547 if args.BaseURL == "" && resolvedModel.BaseURL != "" && (provName != provider.LMStudioProviderID || resolvedModel.Source == "user") {
548 // LM Studio discovery can retain an older snapshot on failure.
549 // Use its currently registered endpoint below, never a stale URL
550 // with the credentials for a newly registered server.
551 args.BaseURL = resolvedModel.BaseURL
552 }
553 if args.BaseURL == "" && provName == "ollama" {
554 args.BaseURL = "http://localhost:11434"
555 }
556 if args.BaseURL == "" && provName == provider.LMStudioProviderID {
557 root, _, configErr := resolveLMStudioConfig(context.Background(), apiKeyCommandExecute)
558 if configErr != nil {
559 return Resolved{}, configErr
560 }
561 if root == "" {
562 return Resolved{}, fmt.Errorf("LM Studio requires a server URL registered through /login or an explicit --base-url")
563 }
564 args.BaseURL = root + "/v1"
565 }
566 if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
567 managementURL, _, configErr := ResolveLlamaCPPConfig()
568 if configErr != nil {
569 return Resolved{}, configErr
570 }
571 var baseErr error
572 args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
573 if baseErr != nil {
574 return Resolved{}, baseErr
575 }
576 }
577
578 // Insecure TLS is intentionally scoped to explicit custom endpoints.
579 // Built-in provider base URLs, auth calls, and model discovery keep normal
580 // certificate verification even when --insecure is present.
581 insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
582
583 // If the model has a base URL, credentials are optional (local
584 // models like ollama don't need real API keys).
585 if resolvedModel.BaseURL != "" && credErr != nil {
586 cred = "ollama"
587 credErr = nil
588 requireCred = false
589 }
590
591 if credErr != nil && requireCred {
592 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
593 credErr, envVarName(provName))
594 }
595
596 sandbox := tools.NewSandbox(args.CWD)
597 if cfg.JailByDefault != nil && *cfg.JailByDefault {
598 sandbox.Lock()
599 }
600 if args.PermissionSet != nil {
601 sandbox.SetPermissions(args.PermissionSet)
602 }
603 reg := buildConfiguredToolRegistry(args, args.CWD, sandbox, cfg, runtime.GOOS)
604 if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() {
605 reg = withOpenRouterServerTools(reg)
606 }
607
608 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
609
610 // Skill discovery: scan project + global locations + built-in
611 // skills shipped with the binary. If any are found, register
612 // the on-demand `skill` loader tool plus a system-prompt
613 // manifest so the model knows what's available.
614 //
615 // --no-skill bypasses the entire mechanism: no manifest in the
616 // system prompt, no `skill` tool in the registry. Useful for a
617 // clean-room run with zero extra context biasing the model.
618 var (
619 discovered []*skills.Skill
620 skillTool *skills.Tool
621 skillAddendum string
622 skillDiagnostics []string
623 )
624 if !args.NoSkill {
625 homeDir, _ := os.UserHomeDir()
626 var sources []skills.Source
627 if args.WithSkills {
628 for _, loc := range skills.SearchSources(ZotHome(), args.CWD, homeDir) {
629 sources = append(sources, loc)
630 }
631 }
632 if !args.NoExt || len(args.Exts) > 0 {
633 extSources, errs := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt)
634 sources = append(extSources, sources...)
635 for _, err := range errs {
636 skillDiagnostics = append(skillDiagnostics, err.Error())
637 }
638 }
639 var errs []error
640 discovered, errs = skills.DiscoverSources(sources, true)
641 for _, err := range errs {
642 skillDiagnostics = append(skillDiagnostics, err.Error())
643 }
644 if len(discovered) > 0 {
645 skillTool = skills.NewTool(discovered)
646 reg[skillTool.Name()] = skillTool
647 skillAddendum = skills.SystemPromptAddendum(discovered)
648 }
649 }
650 _ = skillTool
651
652 summaries := toolSummaries(reg, args)
653
654 var contextFiles []ContextFile
655 if !args.NoContextFiles {
656 contextFiles = loadAgentsContext(args.CWD, ZotHome())
657 }
658 append_ := append([]string(nil), args.AppendSystemPrompt...)
659 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
660 append_ = append(append_, agentsAddendum)
661 }
662 if skillAddendum != "" {
663 append_ = append(append_, skillAddendum)
664 }
665 if AutoSwarmEnabled() {
666 append_ = append(append_, AutoSwarmSystemAddendum)
667 }
668
669 // Custom system prompt resolution order:
670 // 1. --system-prompt flag (highest priority; ad-hoc per run)
671 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
672 // 3. built-in default (defaultIdentity + defaultGuidelines)
673 custom := args.SystemPrompt
674 customSet := args.SystemPromptSet || custom != ""
675 if !customSet {
676 custom = readUserSystemPrompt(ZotHome())
677 customSet = custom != ""
678 }
679
680 sys := BuildSystemPrompt(SystemPromptOpts{
681 CWD: args.CWD,
682 Tools: summaries,
683 Custom: custom,
684 CustomSet: customSet,
685 Append: append_,
686 ZotDocsDir: docsDir,
687 })
688
689 reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
690 temperature := args.Temperature
691 if temperature == nil {
692 temperature = cfg.Temperature
693 }
694
695 max := args.MaxSteps // 0 = unlimited
696
697 return Resolved{
698 Provider: provName,
699 Model: model,
700 Credential: cred,
701 AuthMethod: method,
702 AccountID: accountID,
703 BaseURL: args.BaseURL,
704 InsecureTLS: insecureTLS,
705 CWD: args.CWD,
706 Reasoning: reasoning,
707 Temperature: temperature,
708 ToolRegistry: reg,
709 ToolSummary: summaries,
710 SystemPrompt: sys,
711 MaxSteps: max,
712 MaxOutput: resolvedModel.MaxOutput,
713 Sandbox: sandbox,
714 SkillTool: skillTool,
715 SkillDiagnostics: skillDiagnostics,
716 ContextFiles: contextFiles,
717 systemAppend: append_,
718 systemCustom: custom,
719 systemCustomSet: customSet,
720 toolDescriptions: descMapFromSummaries(summaries),
721 }, nil
722}
723
724// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
725// trimmed contents, or "" when the file is missing / unreadable /
726// empty. Errors are intentionally swallowed: the file is optional,
727// and any failure to read it should fall back to the built-in
728// default system prompt rather than crash the run.
729func readUserSystemPrompt(zotHome string) string {
730 if zotHome == "" {
731 return ""
732 }
733 path := filepath.Join(zotHome, "SYSTEM.md")
734 raw, err := os.ReadFile(path)
735 if err != nil {
736 return ""
737 }
738 return strings.TrimSpace(string(raw))
739}
740
741// loadAgentsContext loads optional AGENTS.md instruction files. No
742// default file is created or required: zot only includes files that
743// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
744// followed by project instructions from the filesystem root down to cwd.
745func loadAgentsContext(cwd, zotHome string) []ContextFile {
746 var files []ContextFile
747 seen := map[string]bool{}
748 add := func(path string) {
749 if path == "" {
750 return
751 }
752 abs, err := filepath.Abs(path)
753 if err == nil {
754 path = abs
755 }
756 if seen[path] {
757 return
758 }
759 raw, err := os.ReadFile(path)
760 if err != nil {
761 return
762 }
763 content := strings.TrimSpace(string(raw))
764 if content == "" {
765 return
766 }
767 seen[path] = true
768 files = append(files, ContextFile{Path: path, Content: content})
769 }
770 addFirstFromDir := func(dir string) {
771 if dir == "" {
772 return
773 }
774 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
775 path := filepath.Join(dir, name)
776 if _, err := os.Stat(path); err == nil {
777 add(path)
778 return
779 }
780 }
781 }
782
783 addFirstFromDir(zotHome)
784
785 if cwd != "" {
786 abs, err := filepath.Abs(cwd)
787 if err == nil {
788 cwd = abs
789 }
790 var dirs []string
791 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
792 dirs = append(dirs, dir)
793 parent := filepath.Dir(dir)
794 if parent == dir {
795 break
796 }
797 }
798 for i := len(dirs) - 1; i >= 0; i-- {
799 addFirstFromDir(dirs[i])
800 }
801 }
802
803 return files
804}
805
806func formatAgentsContext(files []ContextFile) string {
807 if len(files) == 0 {
808 return ""
809 }
810 var sb strings.Builder
811 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")
812 for _, f := range files {
813 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
814 }
815 return strings.TrimSpace(sb.String())
816}
817
818// descMapFromSummaries indexes the human-readable descriptions for
819// the renderToolsSection rebuild path.
820func descMapFromSummaries(summaries []ToolSummary) map[string]string {
821 out := make(map[string]string, len(summaries))
822 for _, s := range summaries {
823 out[s.Name] = s.Description
824 }
825 return out
826}
827
828// NewClient returns a provider.Client for r, choosing the auth mode
829// based on r.AuthMethod. Panics if no credential is present; callers
830// must check HasCredential() first.
831func (r Resolved) NewClient() provider.Client {
832 if !r.HasCredential() {
833 panic("NewClient called without credential; check HasCredential first")
834 }
835 wrap := r.withHTTPClient
836 switch r.Provider {
837 case "ollama":
838 return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
839 case provider.LlamaCPPProviderID, provider.LMStudioProviderID:
840 credential := r.Credential
841 if credential == "local" {
842 credential = ""
843 }
844 return wrap(provider.NewOpenAI(credential, r.BaseURL))
845 case "kimi":
846 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
847 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
848 inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
849 if r.AuthMethod == "oauth" {
850 return r.wrapWithRefresh(inner)
851 }
852 return inner
853 case "moonshotai":
854 return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
855 case "moonshotai-cn":
856 return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
857 case "deepseek":
858 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
859 case "openai":
860 return provider.NewModelRouter("openai",
861 wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
862 map[string]provider.Client{
863 provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
864 })
865 case "openai-codex":
866 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
867 return r.wrapWithRefresh(inner)
868 case "openai-responses":
869 // Public OpenAI Responses API (api.openai.com/v1/responses) via
870 // API key. Separate provider from `openai` (Chat Completions) and
871 // from `openai-codex` (ChatGPT subscription OAuth).
872 return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
873 case "google":
874 return wrap(provider.NewGemini(r.Credential, r.BaseURL))
875 case "cerebras":
876 return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
877 case "groq":
878 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
879 case "xai":
880 inner := r.newXAIClient(r.Credential)
881 if r.AuthMethod == "oauth" {
882 return r.wrapWithRefresh(inner)
883 }
884 return inner
885 case "together":
886 return wrap(provider.NewTogether(r.Credential, r.BaseURL))
887 case "huggingface":
888 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
889 case "openrouter":
890 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
891 case "gondola":
892 return wrap(provider.NewGondola(r.Credential, r.BaseURL))
893 case "zai":
894 return wrap(provider.NewZAI(r.Credential, r.BaseURL))
895 case "xiaomi":
896 return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
897 case "xiaomi-token-plan-ams":
898 return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
899 case "xiaomi-token-plan-cn":
900 return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
901 case "xiaomi-token-plan-sgp":
902 return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
903 case "opencode":
904 return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
905 case "opencode-go":
906 return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
907 case "minimax":
908 return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
909 case "minimax-cn":
910 return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
911 case "fireworks":
912 return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
913 case "vercel-ai-gateway":
914 return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
915 case "mistral":
916 return wrap(provider.NewMistral(r.Credential, r.BaseURL))
917 case "amazon-bedrock":
918 return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
919 case "google-vertex":
920 return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
921 case "azure-openai-responses":
922 return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
923 case "github-copilot":
924 return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
925 case "cloudflare-workers-ai":
926 return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
927 case "cloudflare-ai-gateway":
928 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
929 default:
930 // Custom providers: choose wire format from the models.json api field.
931 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
932 switch cfg.API {
933 case provider.APIResponses:
934 return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
935 case "anthropic":
936 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
937 default: // "openai"
938 return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
939 }
940 }
941 if r.AuthMethod == "oauth" {
942 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
943 return r.wrapWithRefresh(inner)
944 }
945 return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
946 }
947}
948
949func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
950 if !r.InsecureTLS {
951 return c
952 }
953 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
954}
955
956func (r Resolved) newXAIClient(token string) provider.Client {
957 return provider.NewModelRouter("xai",
958 r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
959 map[string]provider.Client{
960 provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
961 })
962}
963
964// wrapWithRefresh wraps an OAuth client so the access token is
965// refreshed automatically before each API call. Without this, long
966// sessions (hours) silently fail when the 1-hour token expires.
967func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
968 provName := r.Provider
969 tokenProvider := provName
970 if provName == "openai-codex" {
971 tokenProvider = "openai"
972 }
973 baseURL := r.BaseURL
974 accountID := r.AccountID
975
976 refreshFn := func(ctx context.Context) (string, error) {
977 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
978 if err != nil {
979 return "", err
980 }
981 return tok.AccessToken, nil
982 }
983
984 factory := func(token string) provider.Client {
985 switch provName {
986 case "openai-codex":
987 return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
988 case "kimi":
989 // anthropic-messages on api.kimi.com/coding.
990 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
991 case "xai":
992 return r.newXAIClient(token)
993 default:
994 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
995 }
996 }
997
998 return provider.NewRefreshingClient(inner, refreshFn, factory)
999}
1000
1001// UseSandbox replaces the sandbox pointer that every tool in r's
1002// registry references. Used to keep the /jail state stable across
1003// agent rebuilds (e.g. /login, /model switching providers).
1004func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1005 if s == nil || r == nil {
1006 return
1007 }
1008 if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1009 s.Permissions = r.Sandbox.Permissions
1010 }
1011 r.Sandbox = s
1012 for name, t := range r.ToolRegistry {
1013 switch v := t.(type) {
1014 case *tools.ReadTool:
1015 v.Sandbox = s
1016 case *tools.WriteTool:
1017 v.Sandbox = s
1018 case *tools.EditTool:
1019 v.Sandbox = s
1020 case *tools.BashTool:
1021 v.Sandbox = s
1022 case *tools.PowerShellTool:
1023 v.Sandbox = s
1024 case *tools.GlobTool:
1025 v.Sandbox = s
1026 }
1027 _ = name
1028 }
1029}
1030
1031// NewAgent constructs a core.Agent from r. Requires a credential.
1032func (r Resolved) NewAgent() *core.Agent {
1033 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1034 a.MaxSteps = r.MaxSteps
1035 a.MaxTokens = r.MaxOutput
1036 a.Reasoning = r.Reasoning
1037 a.Temperature = r.Temperature
1038 if hasOpenRouterServerTools(r.ToolRegistry) {
1039 a.MaxToolCalls = openRouterServerToolCallLimit()
1040 }
1041 return a
1042}
1043
1044// openRouterServerToolCallLimit returns the configured call limit from
1045// config.json, falling back to the built-in default (4).
1046func openRouterServerToolCallLimit() int {
1047 cfg, err := LoadConfig()
1048 if err != nil {
1049 return tools.OpenRouterServerToolCallLimit
1050 }
1051 if cfg.OpenRouterServerToolCallLimit != nil && *cfg.OpenRouterServerToolCallLimit > 0 {
1052 return *cfg.OpenRouterServerToolCallLimit
1053 }
1054 return tools.OpenRouterServerToolCallLimit
1055}
1056
1057// OpenRouterServerToolsEnabled reports the persisted preference.
1058// nil/missing and unreadable configuration fail closed to disabled.
1059func OpenRouterServerToolsEnabled() bool {
1060 cfg, err := LoadConfig()
1061 if err != nil {
1062 return false
1063 }
1064 return cfg.OpenRouterServerToolsEnabled != nil && *cfg.OpenRouterServerToolsEnabled
1065}
1066
1067func isOpenRouterPresetModel(model string) bool {
1068 return strings.Contains(strings.ToLower(model), "@preset/")
1069}
1070
1071func hasOpenRouterServerTools(reg core.Registry) bool {
1072 for name := range reg {
1073 if tools.IsOpenRouterServerTool(name) {
1074 return true
1075 }
1076 }
1077 return false
1078}
1079
1080func withOpenRouterServerTools(reg core.Registry) core.Registry {
1081 out := core.Registry{}
1082 for name, t := range reg {
1083 out[name] = t
1084 }
1085 for _, t := range tools.OpenRouterServerTools() {
1086 out[t.Name()] = t
1087 }
1088 return out
1089}
1090
1091func buildConfiguredToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, cfg Config, goos string) core.Registry {
1092 reg := buildToolRegistry(args, cwd, sandbox)
1093 if goos == "windows" && !args.NoTools && len(args.Tools) == 0 && cfg.PowerShellEnabled != nil && *cfg.PowerShellEnabled {
1094 reg["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1095 }
1096 return reg
1097}
1098
1099func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
1100 if args.NoTools {
1101 return core.Registry{}
1102 }
1103 all := map[string]core.Tool{
1104 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1105 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
1106 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
1107 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1108 "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1109 }
1110 reg := core.Registry{}
1111 if len(args.Tools) == 0 {
1112 for _, t := range all {
1113 reg[t.Name()] = t
1114 }
1115 return reg
1116 }
1117 // PowerShell is explicitly selected, never added to the default tool set.
1118 all["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1119 for _, name := range args.Tools {
1120 if t, ok := all[name]; ok {
1121 reg[name] = t
1122 }
1123 }
1124 return reg
1125}
1126
1127func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1128 order := []string{"read", "write", "edit", "bash", "powershell", "glob"}
1129 var out []ToolSummary
1130 for _, name := range order {
1131 if t, ok := reg[name]; ok {
1132 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1133 }
1134 }
1135 return out
1136}
1137
1138func firstNonEmpty(vals ...string) string {
1139 for _, v := range vals {
1140 if v != "" {
1141 return v
1142 }
1143 }
1144 return ""
1145}
1146
1147func kimiCodeHeaders() map[string]string {
1148 host, _ := os.Hostname()
1149 if host == "" {
1150 host = "unknown"
1151 }
1152 deviceID := ""
1153 if home, err := os.UserHomeDir(); err == nil {
1154 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1155 deviceID = strings.TrimSpace(string(b))
1156 }
1157 }
1158 if deviceID == "" {
1159 deviceID = "zot"
1160 }
1161 return map[string]string{
1162 "User-Agent": "KimiCLI/1.41.0",
1163 "X-Msh-Platform": "kimi_cli",
1164 "X-Msh-Version": "1.41.0",
1165 "X-Msh-Device-Name": host,
1166 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1167 "X-Msh-Os-Version": runtime.GOOS,
1168 "X-Msh-Device-Id": deviceID,
1169 }
1170}
1171
1172func envVarName(provider string) string {
1173 switch provider {
1174 case "openai", "openai-codex", "openai-responses":
1175 return "OPENAI"
1176 case "kimi":
1177 return "KIMI"
1178 case "deepseek":
1179 return "DEEPSEEK"
1180 case "google":
1181 return "GEMINI"
1182 case "ollama":
1183 return "OLLAMA"
1184 case "llama.cpp":
1185 return "LLAMA"
1186 case "moonshotai", "moonshotai-cn":
1187 return "MOONSHOT"
1188 case "groq":
1189 return "GROQ"
1190 case "xai":
1191 return "XAI"
1192 case "cerebras":
1193 return "CEREBRAS"
1194 case "together":
1195 return "TOGETHER"
1196 case "huggingface":
1197 return "HF"
1198 case "openrouter":
1199 return "OPENROUTER"
1200 case "gondola":
1201 return "GONDOLA"
1202 case "mistral":
1203 return "MISTRAL"
1204 case "zai":
1205 return "ZAI"
1206 case "xiaomi":
1207 return "XIAOMI"
1208 case "xiaomi-token-plan-ams":
1209 return "XIAOMI_TOKEN_PLAN_AMS"
1210 case "xiaomi-token-plan-cn":
1211 return "XIAOMI_TOKEN_PLAN_CN"
1212 case "xiaomi-token-plan-sgp":
1213 return "XIAOMI_TOKEN_PLAN_SGP"
1214 case "minimax":
1215 return "MINIMAX"
1216 case "minimax-cn":
1217 return "MINIMAX_CN"
1218 case "fireworks":
1219 return "FIREWORKS"
1220 case "vercel-ai-gateway":
1221 return "AI_GATEWAY"
1222 case "opencode", "opencode-go":
1223 return "OPENCODE"
1224 case "github-copilot":
1225 return "COPILOT_GITHUB_TOKEN"
1226 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1227 return "CLOUDFLARE"
1228 case "amazon-bedrock":
1229 return "AWS"
1230 case "google-vertex":
1231 return "GOOGLE_CLOUD"
1232 case "azure-openai-responses":
1233 return "AZURE_OPENAI"
1234 default:
1235 return "ANTHROPIC"
1236 }
1237}