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