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 args.inheritedCredential != "" {
355 cred = args.inheritedCredential
356 method = args.inheritedAuthMethod
357 accountID = args.inheritedAccountID
358 } else if provName == "ollama" {
359 cred = firstNonEmpty(args.APIKey, "ollama")
360 method = "apikey"
361 } else {
362 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
363 }
364
365 // Persist --api-key for custom providers so subsequent runs don't
366 // need to pass it again.
367 if !isBuiltinProvider(provName) && args.APIKey != "" {
368 if store := AuthStoreFor(); store != nil {
369 _ = store.SetAPIKey(provName, args.APIKey)
370 }
371 }
372
373 // If the user did NOT explicitly pick a provider (neither via --provider
374 // nor by saving one in config.json) and the default one has no
375 // credentials, auto-fall-back to whichever provider is actually logged
376 // in. That way running plain `zot` after `/login` (any provider) never
377 // shows a "not logged in" banner.
378 //
379 // Critical: when the user HAS saved a provider in config.json (e.g.
380 // "deepseek" with "deepseek-v4-flash"), that choice is respected even
381 // if no credential is found — the tui will show the login dialog or
382 // surface a clear credential error rather than silently switching to
383 // a completely different provider and model.
384 // autoFellBack tracks whether the provider was changed by the
385 // auto-fallback loop (no explicit provider from CLI or config,
386 // and the default had no credentials). When false, the provider
387 // came from the user's explicit choice (--provider or config.json)
388 // and must be respected, including any model id that may belong
389 // to a different provider in the catalog — for example when using
390 // a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash".
391 var autoFellBack bool
392 userPickedProvider := args.Provider != "" || cfg.Provider != ""
393 if credErr != nil && !userPickedProvider && provName != "ollama" {
394 autoFellBack = true
395 // Scan every known provider (not a hardcoded subset) so any
396 // env-based credential is discovered, e.g. an env-only
397 // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
398 // IAM keys) when no config.json pins the provider, such as after
399 // pointing ZOT_HOME at a fresh home dir. Iteration order of
400 // knownProviders defines fallback priority. Local providers without a
401 // default model are skipped because selecting either one here would
402 // fail before the user can choose a model.
403 for _, other := range knownProviders {
404 if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {
405 continue
406 }
407 if !CredentialAvailable(other) {
408 continue
409 }
410 c, m, a, err := ResolveCredentialFull(other, args.APIKey)
411 provName = other
412 cred, method, accountID, credErr = c, m, a, err
413 break
414 }
415 }
416
417 model := firstNonEmpty(args.Model, cfg.Model)
418 if model == "" {
419 if provName == "ollama" || provName == provider.LlamaCPPProviderID {
420 return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
421 }
422 model = defaultModelForProvider(provName)
423 }
424 // If the resolved model belongs to a different provider (e.g. config
425 // says gpt-5 but we auto-fell back to anthropic), pick that provider's
426 // default. This override only fires when the provider was auto-fallback-
427 // changed, NOT when the user explicitly configured the pair — gateway/
428 // router providers (openrouter, vercel-ai-gateway, etc.) can serve
429 // models from any provider in the catalog.
430 if autoFellBack && provName != "ollama" {
431 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
432 model = defaultModelForProvider(provName)
433 }
434 }
435 resolvedModel, err := provider.FindModel(provName, model)
436 if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) {
437 // Local providers are intentionally open-catalogue: any model id the
438 // configured server understands is valid, even if it is not cached.
439 resolvedModel = provider.Model{
440 Provider: provName,
441 ID: model,
442 DisplayName: model,
443 ContextWindow: 128000,
444 MaxOutput: 16384,
445 BaseURL: args.BaseURL,
446 Source: provName,
447 }
448 err = nil
449 }
450 // Custom providers are open-catalogue like ollama: any model id the
451 // endpoint understands is valid. Use the provider-level base URL.
452 if err != nil {
453 if cfg, ok := provider.CustomProviders()[provName]; ok {
454 resolvedModel = provider.Model{
455 Provider: provName,
456 ID: model,
457 DisplayName: model,
458 BaseURL: cfg.BaseURL,
459 Source: "user",
460 }
461 err = nil
462 }
463 }
464 if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
465 // Fall back to the provider-level base URL when the model does
466 // not define its own endpoint.
467 resolvedModel.BaseURL = cfg.BaseURL
468 }
469 if err != nil {
470 // The model the user (or persisted config) asked for is no
471 // longer in the active catalogue — they probably removed it
472 // from their models.json or upgraded zot and the id changed.
473 // Refusing to launch is the wrong move: it strands the user
474 // with no way to even open the TUI and pick a new model.
475 // Fall back to the provider's default, warn on stderr, and,
476 // when the stale id came from the persisted config (not an
477 // explicit --model flag), repair the config so the warning
478 // doesn't repeat on every launch.
479
480 // Gateway providers can accept route-qualified ids that are not in
481 // zot's local catalog yet, for example OpenRouter's
482 // "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
483 // plain unknown values are likely typos and should still fall back.
484 if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
485 resolvedModel = provider.Model{
486 Provider: provName,
487 ID: model,
488 DisplayName: model,
489 ContextWindow: 1000000,
490 MaxOutput: 64000,
491 BaseURL: "",
492 Source: "gateway",
493 }
494 err = nil
495 // Don't repair config — the routed model id may be valid upstream.
496 } else {
497 fallback := defaultModelForProvider(provName)
498 fm, ferr := provider.FindModel(provName, fallback)
499 if ferr != nil {
500 // Even the provider default is gone (catastrophic
501 // catalogue trim). Last resort: any model on this
502 // provider, then the global DefaultModel.
503 if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
504 fm = candidates[0]
505 ferr = nil
506 } else {
507 fm = provider.DefaultModel
508 ferr = nil
509 }
510 }
511 fmt.Fprintf(os.Stderr,
512 "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
513 model, fm.ID)
514 if args.Model == "" && cfg.Model == model {
515 cfg.Model = fm.ID
516 _ = SaveConfig(cfg)
517 }
518 resolvedModel = fm
519 model = fm.ID
520 }
521 }
522
523 explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
524
525 // If the model defines a base URL (e.g. local ollama) and the
526 // user didn't pass --base-url, use the model's URL. For ollama,
527 // keep http://localhost:11434 as a fallback only after the model
528 // metadata has had a chance to provide a custom baseUrl.
529 if args.BaseURL == "" && resolvedModel.BaseURL != "" {
530 args.BaseURL = resolvedModel.BaseURL
531 }
532 if args.BaseURL == "" && provName == "ollama" {
533 args.BaseURL = "http://localhost:11434"
534 }
535 if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
536 managementURL, _, configErr := ResolveLlamaCPPConfig()
537 if configErr != nil {
538 return Resolved{}, configErr
539 }
540 var baseErr error
541 args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
542 if baseErr != nil {
543 return Resolved{}, baseErr
544 }
545 }
546
547 // Insecure TLS is intentionally scoped to explicit custom endpoints.
548 // Built-in provider base URLs, auth calls, and model discovery keep normal
549 // certificate verification even when --insecure is present.
550 insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
551
552 // If the model has a base URL, credentials are optional (local
553 // models like ollama don't need real API keys).
554 if resolvedModel.BaseURL != "" && credErr != nil {
555 cred = "ollama"
556 credErr = nil
557 requireCred = false
558 }
559
560 if credErr != nil && requireCred {
561 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
562 credErr, envVarName(provName))
563 }
564
565 sandbox := tools.NewSandbox(args.CWD)
566 if cfg.JailByDefault != nil && *cfg.JailByDefault {
567 sandbox.Lock()
568 }
569 if args.PermissionSet != nil {
570 sandbox.SetPermissions(args.PermissionSet)
571 }
572 reg := buildToolRegistry(args, args.CWD, sandbox)
573
574 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
575
576 // Skill discovery: scan project + global locations + built-in
577 // skills shipped with the binary. If any are found, register
578 // the on-demand `skill` loader tool plus a system-prompt
579 // manifest so the model knows what's available.
580 //
581 // --no-skill bypasses the entire mechanism: no manifest in the
582 // system prompt, no `skill` tool in the registry. Useful for a
583 // clean-room run with zero extra context biasing the model.
584 var (
585 discovered []*skills.Skill
586 skillTool *skills.Tool
587 skillAddendum string
588 )
589 if !args.NoSkill {
590 homeDir, _ := os.UserHomeDir()
591 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
592 if len(discovered) > 0 {
593 skillTool = skills.NewTool(discovered)
594 reg[skillTool.Name()] = skillTool
595 skillAddendum = skills.SystemPromptAddendum(discovered)
596 }
597 }
598 _ = skillTool
599
600 summaries := toolSummaries(reg, args)
601
602 var contextFiles []ContextFile
603 if !args.NoContextFiles {
604 contextFiles = loadAgentsContext(args.CWD, ZotHome())
605 }
606 append_ := append([]string(nil), args.AppendSystemPrompt...)
607 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
608 append_ = append(append_, agentsAddendum)
609 }
610 if skillAddendum != "" {
611 append_ = append(append_, skillAddendum)
612 }
613 if AutoSwarmEnabled() {
614 append_ = append(append_, AutoSwarmSystemAddendum)
615 }
616
617 // Custom system prompt resolution order:
618 // 1. --system-prompt flag (highest priority; ad-hoc per run)
619 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
620 // 3. built-in default (defaultIdentity + defaultGuidelines)
621 custom := args.SystemPrompt
622 if custom == "" {
623 custom = readUserSystemPrompt(ZotHome())
624 }
625
626 sys := BuildSystemPrompt(SystemPromptOpts{
627 CWD: args.CWD,
628 Tools: summaries,
629 Custom: custom,
630 Append: append_,
631 ZotDocsDir: docsDir,
632 })
633
634 reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
635 temperature := args.Temperature
636 if temperature == nil {
637 temperature = cfg.Temperature
638 }
639
640 max := args.MaxSteps // 0 = unlimited
641
642 return Resolved{
643 Provider: provName,
644 Model: model,
645 Credential: cred,
646 AuthMethod: method,
647 AccountID: accountID,
648 BaseURL: args.BaseURL,
649 InsecureTLS: insecureTLS,
650 CWD: args.CWD,
651 Reasoning: reasoning,
652 Temperature: temperature,
653 ToolRegistry: reg,
654 ToolSummary: summaries,
655 SystemPrompt: sys,
656 MaxSteps: max,
657 MaxOutput: resolvedModel.MaxOutput,
658 Sandbox: sandbox,
659 SkillTool: skillTool,
660 ContextFiles: contextFiles,
661 systemAppend: append_,
662 systemCustom: custom,
663 toolDescriptions: descMapFromSummaries(summaries),
664 }, nil
665}
666
667// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
668// trimmed contents, or "" when the file is missing / unreadable /
669// empty. Errors are intentionally swallowed: the file is optional,
670// and any failure to read it should fall back to the built-in
671// default system prompt rather than crash the run.
672func readUserSystemPrompt(zotHome string) string {
673 if zotHome == "" {
674 return ""
675 }
676 path := filepath.Join(zotHome, "SYSTEM.md")
677 raw, err := os.ReadFile(path)
678 if err != nil {
679 return ""
680 }
681 return strings.TrimSpace(string(raw))
682}
683
684// loadAgentsContext loads optional AGENTS.md instruction files. No
685// default file is created or required: zot only includes files that
686// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
687// followed by project instructions from the filesystem root down to cwd.
688func loadAgentsContext(cwd, zotHome string) []ContextFile {
689 var files []ContextFile
690 seen := map[string]bool{}
691 add := func(path string) {
692 if path == "" {
693 return
694 }
695 abs, err := filepath.Abs(path)
696 if err == nil {
697 path = abs
698 }
699 if seen[path] {
700 return
701 }
702 raw, err := os.ReadFile(path)
703 if err != nil {
704 return
705 }
706 content := strings.TrimSpace(string(raw))
707 if content == "" {
708 return
709 }
710 seen[path] = true
711 files = append(files, ContextFile{Path: path, Content: content})
712 }
713 addFirstFromDir := func(dir string) {
714 if dir == "" {
715 return
716 }
717 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
718 path := filepath.Join(dir, name)
719 if _, err := os.Stat(path); err == nil {
720 add(path)
721 return
722 }
723 }
724 }
725
726 addFirstFromDir(zotHome)
727
728 if cwd != "" {
729 abs, err := filepath.Abs(cwd)
730 if err == nil {
731 cwd = abs
732 }
733 var dirs []string
734 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
735 dirs = append(dirs, dir)
736 parent := filepath.Dir(dir)
737 if parent == dir {
738 break
739 }
740 }
741 for i := len(dirs) - 1; i >= 0; i-- {
742 addFirstFromDir(dirs[i])
743 }
744 }
745
746 return files
747}
748
749func formatAgentsContext(files []ContextFile) string {
750 if len(files) == 0 {
751 return ""
752 }
753 var sb strings.Builder
754 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")
755 for _, f := range files {
756 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
757 }
758 return strings.TrimSpace(sb.String())
759}
760
761// descMapFromSummaries indexes the human-readable descriptions for
762// the renderToolsSection rebuild path.
763func descMapFromSummaries(summaries []ToolSummary) map[string]string {
764 out := make(map[string]string, len(summaries))
765 for _, s := range summaries {
766 out[s.Name] = s.Description
767 }
768 return out
769}
770
771// NewClient returns a provider.Client for r, choosing the auth mode
772// based on r.AuthMethod. Panics if no credential is present; callers
773// must check HasCredential() first.
774func (r Resolved) NewClient() provider.Client {
775 if !r.HasCredential() {
776 panic("NewClient called without credential; check HasCredential first")
777 }
778 wrap := r.withHTTPClient
779 switch r.Provider {
780 case "ollama":
781 return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
782 case provider.LlamaCPPProviderID:
783 credential := r.Credential
784 if credential == "local" {
785 credential = ""
786 }
787 return wrap(provider.NewOpenAI(credential, r.BaseURL))
788 case "kimi":
789 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
790 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
791 inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
792 if r.AuthMethod == "oauth" {
793 return r.wrapWithRefresh(inner)
794 }
795 return inner
796 case "moonshotai":
797 return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
798 case "moonshotai-cn":
799 return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
800 case "deepseek":
801 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
802 case "openai":
803 return provider.NewModelRouter("openai",
804 wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
805 map[string]provider.Client{
806 provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
807 })
808 case "openai-codex":
809 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
810 return r.wrapWithRefresh(inner)
811 case "openai-responses":
812 // Public OpenAI Responses API (api.openai.com/v1/responses) via
813 // API key. Separate provider from `openai` (Chat Completions) and
814 // from `openai-codex` (ChatGPT subscription OAuth).
815 return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
816 case "google":
817 return wrap(provider.NewGemini(r.Credential, r.BaseURL))
818 case "cerebras":
819 return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
820 case "groq":
821 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
822 case "xai":
823 inner := r.newXAIClient(r.Credential)
824 if r.AuthMethod == "oauth" {
825 return r.wrapWithRefresh(inner)
826 }
827 return inner
828 case "together":
829 return wrap(provider.NewTogether(r.Credential, r.BaseURL))
830 case "huggingface":
831 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
832 case "openrouter":
833 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
834 case "zai":
835 return wrap(provider.NewZAI(r.Credential, r.BaseURL))
836 case "xiaomi":
837 return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
838 case "xiaomi-token-plan-ams":
839 return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
840 case "xiaomi-token-plan-cn":
841 return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
842 case "xiaomi-token-plan-sgp":
843 return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
844 case "opencode":
845 return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
846 case "opencode-go":
847 return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
848 case "minimax":
849 return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
850 case "minimax-cn":
851 return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
852 case "fireworks":
853 return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
854 case "vercel-ai-gateway":
855 return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
856 case "mistral":
857 return wrap(provider.NewMistral(r.Credential, r.BaseURL))
858 case "amazon-bedrock":
859 return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
860 case "google-vertex":
861 return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
862 case "azure-openai-responses":
863 return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
864 case "github-copilot":
865 return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
866 case "cloudflare-workers-ai":
867 return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
868 case "cloudflare-ai-gateway":
869 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
870 default:
871 // Custom providers: choose wire format from the models.json api field.
872 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
873 switch cfg.API {
874 case provider.APIResponses:
875 return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
876 case "anthropic":
877 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
878 default: // "openai"
879 return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
880 }
881 }
882 if r.AuthMethod == "oauth" {
883 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
884 return r.wrapWithRefresh(inner)
885 }
886 return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
887 }
888}
889
890func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
891 if !r.InsecureTLS {
892 return c
893 }
894 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
895}
896
897func (r Resolved) newXAIClient(token string) provider.Client {
898 return provider.NewModelRouter("xai",
899 r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
900 map[string]provider.Client{
901 provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
902 })
903}
904
905// wrapWithRefresh wraps an OAuth client so the access token is
906// refreshed automatically before each API call. Without this, long
907// sessions (hours) silently fail when the 1-hour token expires.
908func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
909 provName := r.Provider
910 tokenProvider := provName
911 if provName == "openai-codex" {
912 tokenProvider = "openai"
913 }
914 baseURL := r.BaseURL
915 accountID := r.AccountID
916
917 refreshFn := func(ctx context.Context) (string, error) {
918 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
919 if err != nil {
920 return "", err
921 }
922 return tok.AccessToken, nil
923 }
924
925 factory := func(token string) provider.Client {
926 switch provName {
927 case "openai-codex":
928 return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
929 case "kimi":
930 // anthropic-messages on api.kimi.com/coding.
931 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
932 case "xai":
933 return r.newXAIClient(token)
934 default:
935 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
936 }
937 }
938
939 return provider.NewRefreshingClient(inner, refreshFn, factory)
940}
941
942// UseSandbox replaces the sandbox pointer that every tool in r's
943// registry references. Used to keep the /jail state stable across
944// agent rebuilds (e.g. /login, /model switching providers).
945func (r *Resolved) UseSandbox(s *tools.Sandbox) {
946 if s == nil || r == nil {
947 return
948 }
949 if r.Sandbox != nil && r.Sandbox.Permissions != nil {
950 s.Permissions = r.Sandbox.Permissions
951 }
952 r.Sandbox = s
953 for name, t := range r.ToolRegistry {
954 switch v := t.(type) {
955 case *tools.ReadTool:
956 v.Sandbox = s
957 case *tools.WriteTool:
958 v.Sandbox = s
959 case *tools.EditTool:
960 v.Sandbox = s
961 case *tools.BashTool:
962 v.Sandbox = s
963 }
964 _ = name
965 }
966}
967
968// NewAgent constructs a core.Agent from r. Requires a credential.
969func (r Resolved) NewAgent() *core.Agent {
970 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
971 a.MaxSteps = r.MaxSteps
972 a.MaxTokens = r.MaxOutput
973 a.Reasoning = r.Reasoning
974 a.Temperature = r.Temperature
975 return a
976}
977
978func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
979 if args.NoTools {
980 return core.Registry{}
981 }
982 all := map[string]core.Tool{
983 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
984 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
985 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
986 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
987 }
988 reg := core.Registry{}
989 if len(args.Tools) == 0 {
990 for _, t := range all {
991 reg[t.Name()] = t
992 }
993 return reg
994 }
995 for _, name := range args.Tools {
996 if t, ok := all[name]; ok {
997 reg[name] = t
998 }
999 }
1000 return reg
1001}
1002
1003func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1004 order := []string{"read", "write", "edit", "bash"}
1005 var out []ToolSummary
1006 for _, name := range order {
1007 if t, ok := reg[name]; ok {
1008 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1009 }
1010 }
1011 return out
1012}
1013
1014func firstNonEmpty(vals ...string) string {
1015 for _, v := range vals {
1016 if v != "" {
1017 return v
1018 }
1019 }
1020 return ""
1021}
1022
1023func kimiCodeHeaders() map[string]string {
1024 host, _ := os.Hostname()
1025 if host == "" {
1026 host = "unknown"
1027 }
1028 deviceID := ""
1029 if home, err := os.UserHomeDir(); err == nil {
1030 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1031 deviceID = strings.TrimSpace(string(b))
1032 }
1033 }
1034 if deviceID == "" {
1035 deviceID = "zot"
1036 }
1037 return map[string]string{
1038 "User-Agent": "KimiCLI/1.41.0",
1039 "X-Msh-Platform": "kimi_cli",
1040 "X-Msh-Version": "1.41.0",
1041 "X-Msh-Device-Name": host,
1042 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1043 "X-Msh-Os-Version": runtime.GOOS,
1044 "X-Msh-Device-Id": deviceID,
1045 }
1046}
1047
1048func envVarName(provider string) string {
1049 switch provider {
1050 case "openai", "openai-codex", "openai-responses":
1051 return "OPENAI"
1052 case "kimi":
1053 return "KIMI"
1054 case "deepseek":
1055 return "DEEPSEEK"
1056 case "google":
1057 return "GEMINI"
1058 case "ollama":
1059 return "OLLAMA"
1060 case "llama.cpp":
1061 return "LLAMA"
1062 case "moonshotai", "moonshotai-cn":
1063 return "MOONSHOT"
1064 case "groq":
1065 return "GROQ"
1066 case "xai":
1067 return "XAI"
1068 case "cerebras":
1069 return "CEREBRAS"
1070 case "together":
1071 return "TOGETHER"
1072 case "huggingface":
1073 return "HF"
1074 case "openrouter":
1075 return "OPENROUTER"
1076 case "mistral":
1077 return "MISTRAL"
1078 case "zai":
1079 return "ZAI"
1080 case "xiaomi":
1081 return "XIAOMI"
1082 case "xiaomi-token-plan-ams":
1083 return "XIAOMI_TOKEN_PLAN_AMS"
1084 case "xiaomi-token-plan-cn":
1085 return "XIAOMI_TOKEN_PLAN_CN"
1086 case "xiaomi-token-plan-sgp":
1087 return "XIAOMI_TOKEN_PLAN_SGP"
1088 case "minimax":
1089 return "MINIMAX"
1090 case "minimax-cn":
1091 return "MINIMAX_CN"
1092 case "fireworks":
1093 return "FIREWORKS"
1094 case "vercel-ai-gateway":
1095 return "AI_GATEWAY"
1096 case "opencode", "opencode-go":
1097 return "OPENCODE"
1098 case "github-copilot":
1099 return "COPILOT_GITHUB_TOKEN"
1100 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1101 return "CLOUDFLARE"
1102 case "amazon-bedrock":
1103 return "AWS"
1104 case "google-vertex":
1105 return "GOOGLE_CLOUD"
1106 case "azure-openai-responses":
1107 return "AZURE_OPENAI"
1108 default:
1109 return "ANTHROPIC"
1110 }
1111}