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