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