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 contextFiles := loadAgentsContext(args.CWD, ZotHome())
603 append_ := append([]string(nil), args.AppendSystemPrompt...)
604 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
605 append_ = append(append_, agentsAddendum)
606 }
607 if skillAddendum != "" {
608 append_ = append(append_, skillAddendum)
609 }
610 if AutoSwarmEnabled() {
611 append_ = append(append_, AutoSwarmSystemAddendum)
612 }
613
614 // Custom system prompt resolution order:
615 // 1. --system-prompt flag (highest priority; ad-hoc per run)
616 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
617 // 3. built-in default (defaultIdentity + defaultGuidelines)
618 custom := args.SystemPrompt
619 if custom == "" {
620 custom = readUserSystemPrompt(ZotHome())
621 }
622
623 sys := BuildSystemPrompt(SystemPromptOpts{
624 CWD: args.CWD,
625 Tools: summaries,
626 Custom: custom,
627 Append: append_,
628 ZotDocsDir: docsDir,
629 })
630
631 reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
632 temperature := args.Temperature
633 if temperature == nil {
634 temperature = cfg.Temperature
635 }
636
637 max := args.MaxSteps // 0 = unlimited
638
639 return Resolved{
640 Provider: provName,
641 Model: model,
642 Credential: cred,
643 AuthMethod: method,
644 AccountID: accountID,
645 BaseURL: args.BaseURL,
646 InsecureTLS: insecureTLS,
647 CWD: args.CWD,
648 Reasoning: reasoning,
649 Temperature: temperature,
650 ToolRegistry: reg,
651 ToolSummary: summaries,
652 SystemPrompt: sys,
653 MaxSteps: max,
654 MaxOutput: resolvedModel.MaxOutput,
655 Sandbox: sandbox,
656 SkillTool: skillTool,
657 ContextFiles: contextFiles,
658 systemAppend: append_,
659 systemCustom: custom,
660 toolDescriptions: descMapFromSummaries(summaries),
661 }, nil
662}
663
664// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
665// trimmed contents, or "" when the file is missing / unreadable /
666// empty. Errors are intentionally swallowed: the file is optional,
667// and any failure to read it should fall back to the built-in
668// default system prompt rather than crash the run.
669func readUserSystemPrompt(zotHome string) string {
670 if zotHome == "" {
671 return ""
672 }
673 path := filepath.Join(zotHome, "SYSTEM.md")
674 raw, err := os.ReadFile(path)
675 if err != nil {
676 return ""
677 }
678 return strings.TrimSpace(string(raw))
679}
680
681// loadAgentsContext loads optional AGENTS.md instruction files. No
682// default file is created or required: zot only includes files that
683// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
684// followed by project instructions from the filesystem root down to cwd.
685func loadAgentsContext(cwd, zotHome string) []ContextFile {
686 var files []ContextFile
687 seen := map[string]bool{}
688 add := func(path string) {
689 if path == "" {
690 return
691 }
692 abs, err := filepath.Abs(path)
693 if err == nil {
694 path = abs
695 }
696 if seen[path] {
697 return
698 }
699 raw, err := os.ReadFile(path)
700 if err != nil {
701 return
702 }
703 content := strings.TrimSpace(string(raw))
704 if content == "" {
705 return
706 }
707 seen[path] = true
708 files = append(files, ContextFile{Path: path, Content: content})
709 }
710 addFirstFromDir := func(dir string) {
711 if dir == "" {
712 return
713 }
714 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
715 path := filepath.Join(dir, name)
716 if _, err := os.Stat(path); err == nil {
717 add(path)
718 return
719 }
720 }
721 }
722
723 addFirstFromDir(zotHome)
724
725 if cwd != "" {
726 abs, err := filepath.Abs(cwd)
727 if err == nil {
728 cwd = abs
729 }
730 var dirs []string
731 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
732 dirs = append(dirs, dir)
733 parent := filepath.Dir(dir)
734 if parent == dir {
735 break
736 }
737 }
738 for i := len(dirs) - 1; i >= 0; i-- {
739 addFirstFromDir(dirs[i])
740 }
741 }
742
743 return files
744}
745
746func formatAgentsContext(files []ContextFile) string {
747 if len(files) == 0 {
748 return ""
749 }
750 var sb strings.Builder
751 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")
752 for _, f := range files {
753 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
754 }
755 return strings.TrimSpace(sb.String())
756}
757
758// descMapFromSummaries indexes the human-readable descriptions for
759// the renderToolsSection rebuild path.
760func descMapFromSummaries(summaries []ToolSummary) map[string]string {
761 out := make(map[string]string, len(summaries))
762 for _, s := range summaries {
763 out[s.Name] = s.Description
764 }
765 return out
766}
767
768// NewClient returns a provider.Client for r, choosing the auth mode
769// based on r.AuthMethod. Panics if no credential is present; callers
770// must check HasCredential() first.
771func (r Resolved) NewClient() provider.Client {
772 if !r.HasCredential() {
773 panic("NewClient called without credential; check HasCredential first")
774 }
775 wrap := r.withHTTPClient
776 switch r.Provider {
777 case "ollama":
778 return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
779 case provider.LlamaCPPProviderID:
780 credential := r.Credential
781 if credential == "local" {
782 credential = ""
783 }
784 return wrap(provider.NewOpenAI(credential, r.BaseURL))
785 case "kimi":
786 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
787 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
788 inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
789 if r.AuthMethod == "oauth" {
790 return r.wrapWithRefresh(inner)
791 }
792 return inner
793 case "moonshotai":
794 return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
795 case "moonshotai-cn":
796 return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
797 case "deepseek":
798 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
799 case "openai":
800 return provider.NewModelRouter("openai",
801 wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
802 map[string]provider.Client{
803 provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
804 })
805 case "openai-codex":
806 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
807 return r.wrapWithRefresh(inner)
808 case "openai-responses":
809 // Public OpenAI Responses API (api.openai.com/v1/responses) via
810 // API key. Separate provider from `openai` (Chat Completions) and
811 // from `openai-codex` (ChatGPT subscription OAuth).
812 return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
813 case "google":
814 return wrap(provider.NewGemini(r.Credential, r.BaseURL))
815 case "cerebras":
816 return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
817 case "groq":
818 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
819 case "xai":
820 inner := r.newXAIClient(r.Credential)
821 if r.AuthMethod == "oauth" {
822 return r.wrapWithRefresh(inner)
823 }
824 return inner
825 case "together":
826 return wrap(provider.NewTogether(r.Credential, r.BaseURL))
827 case "huggingface":
828 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
829 case "openrouter":
830 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
831 case "zai":
832 return wrap(provider.NewZAI(r.Credential, r.BaseURL))
833 case "xiaomi":
834 return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
835 case "xiaomi-token-plan-ams":
836 return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
837 case "xiaomi-token-plan-cn":
838 return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
839 case "xiaomi-token-plan-sgp":
840 return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
841 case "opencode":
842 return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
843 case "opencode-go":
844 return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
845 case "minimax":
846 return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
847 case "minimax-cn":
848 return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
849 case "fireworks":
850 return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
851 case "vercel-ai-gateway":
852 return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
853 case "mistral":
854 return wrap(provider.NewMistral(r.Credential, r.BaseURL))
855 case "amazon-bedrock":
856 return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
857 case "google-vertex":
858 return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
859 case "azure-openai-responses":
860 return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
861 case "github-copilot":
862 return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
863 case "cloudflare-workers-ai":
864 return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
865 case "cloudflare-ai-gateway":
866 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
867 default:
868 // Custom providers: choose wire format from the models.json api field.
869 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
870 switch cfg.API {
871 case provider.APIResponses:
872 return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
873 case "anthropic":
874 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
875 default: // "openai"
876 return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
877 }
878 }
879 if r.AuthMethod == "oauth" {
880 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
881 return r.wrapWithRefresh(inner)
882 }
883 return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
884 }
885}
886
887func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
888 if !r.InsecureTLS {
889 return c
890 }
891 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
892}
893
894func (r Resolved) newXAIClient(token string) provider.Client {
895 return provider.NewModelRouter("xai",
896 r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
897 map[string]provider.Client{
898 provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
899 })
900}
901
902// wrapWithRefresh wraps an OAuth client so the access token is
903// refreshed automatically before each API call. Without this, long
904// sessions (hours) silently fail when the 1-hour token expires.
905func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
906 provName := r.Provider
907 tokenProvider := provName
908 if provName == "openai-codex" {
909 tokenProvider = "openai"
910 }
911 baseURL := r.BaseURL
912 accountID := r.AccountID
913
914 refreshFn := func(ctx context.Context) (string, error) {
915 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
916 if err != nil {
917 return "", err
918 }
919 return tok.AccessToken, nil
920 }
921
922 factory := func(token string) provider.Client {
923 switch provName {
924 case "openai-codex":
925 return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
926 case "kimi":
927 // anthropic-messages on api.kimi.com/coding.
928 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
929 case "xai":
930 return r.newXAIClient(token)
931 default:
932 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
933 }
934 }
935
936 return provider.NewRefreshingClient(inner, refreshFn, factory)
937}
938
939// UseSandbox replaces the sandbox pointer that every tool in r's
940// registry references. Used to keep the /jail state stable across
941// agent rebuilds (e.g. /login, /model switching providers).
942func (r *Resolved) UseSandbox(s *tools.Sandbox) {
943 if s == nil || r == nil {
944 return
945 }
946 if r.Sandbox != nil && r.Sandbox.Permissions != nil {
947 s.Permissions = r.Sandbox.Permissions
948 }
949 r.Sandbox = s
950 for name, t := range r.ToolRegistry {
951 switch v := t.(type) {
952 case *tools.ReadTool:
953 v.Sandbox = s
954 case *tools.WriteTool:
955 v.Sandbox = s
956 case *tools.EditTool:
957 v.Sandbox = s
958 case *tools.BashTool:
959 v.Sandbox = s
960 }
961 _ = name
962 }
963}
964
965// NewAgent constructs a core.Agent from r. Requires a credential.
966func (r Resolved) NewAgent() *core.Agent {
967 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
968 a.MaxSteps = r.MaxSteps
969 a.MaxTokens = r.MaxOutput
970 a.Reasoning = r.Reasoning
971 a.Temperature = r.Temperature
972 return a
973}
974
975func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
976 if args.NoTools {
977 return core.Registry{}
978 }
979 all := map[string]core.Tool{
980 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
981 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
982 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
983 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
984 }
985 reg := core.Registry{}
986 if len(args.Tools) == 0 {
987 for _, t := range all {
988 reg[t.Name()] = t
989 }
990 return reg
991 }
992 for _, name := range args.Tools {
993 if t, ok := all[name]; ok {
994 reg[name] = t
995 }
996 }
997 return reg
998}
999
1000func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1001 order := []string{"read", "write", "edit", "bash"}
1002 var out []ToolSummary
1003 for _, name := range order {
1004 if t, ok := reg[name]; ok {
1005 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1006 }
1007 }
1008 return out
1009}
1010
1011func firstNonEmpty(vals ...string) string {
1012 for _, v := range vals {
1013 if v != "" {
1014 return v
1015 }
1016 }
1017 return ""
1018}
1019
1020func kimiCodeHeaders() map[string]string {
1021 host, _ := os.Hostname()
1022 if host == "" {
1023 host = "unknown"
1024 }
1025 deviceID := ""
1026 if home, err := os.UserHomeDir(); err == nil {
1027 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1028 deviceID = strings.TrimSpace(string(b))
1029 }
1030 }
1031 if deviceID == "" {
1032 deviceID = "zot"
1033 }
1034 return map[string]string{
1035 "User-Agent": "KimiCLI/1.41.0",
1036 "X-Msh-Platform": "kimi_cli",
1037 "X-Msh-Version": "1.41.0",
1038 "X-Msh-Device-Name": host,
1039 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1040 "X-Msh-Os-Version": runtime.GOOS,
1041 "X-Msh-Device-Id": deviceID,
1042 }
1043}
1044
1045func envVarName(provider string) string {
1046 switch provider {
1047 case "openai", "openai-codex", "openai-responses":
1048 return "OPENAI"
1049 case "kimi":
1050 return "KIMI"
1051 case "deepseek":
1052 return "DEEPSEEK"
1053 case "google":
1054 return "GEMINI"
1055 case "ollama":
1056 return "OLLAMA"
1057 case "llama.cpp":
1058 return "LLAMA"
1059 case "moonshotai", "moonshotai-cn":
1060 return "MOONSHOT"
1061 case "groq":
1062 return "GROQ"
1063 case "xai":
1064 return "XAI"
1065 case "cerebras":
1066 return "CEREBRAS"
1067 case "together":
1068 return "TOGETHER"
1069 case "huggingface":
1070 return "HF"
1071 case "openrouter":
1072 return "OPENROUTER"
1073 case "mistral":
1074 return "MISTRAL"
1075 case "zai":
1076 return "ZAI"
1077 case "xiaomi":
1078 return "XIAOMI"
1079 case "xiaomi-token-plan-ams":
1080 return "XIAOMI_TOKEN_PLAN_AMS"
1081 case "xiaomi-token-plan-cn":
1082 return "XIAOMI_TOKEN_PLAN_CN"
1083 case "xiaomi-token-plan-sgp":
1084 return "XIAOMI_TOKEN_PLAN_SGP"
1085 case "minimax":
1086 return "MINIMAX"
1087 case "minimax-cn":
1088 return "MINIMAX_CN"
1089 case "fireworks":
1090 return "FIREWORKS"
1091 case "vercel-ai-gateway":
1092 return "AI_GATEWAY"
1093 case "opencode", "opencode-go":
1094 return "OPENCODE"
1095 case "github-copilot":
1096 return "COPILOT_GITHUB_TOKEN"
1097 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1098 return "CLOUDFLARE"
1099 case "amazon-bedrock":
1100 return "AWS"
1101 case "google-vertex":
1102 return "GOOGLE_CLOUD"
1103 case "azure-openai-responses":
1104 return "AZURE_OPENAI"
1105 default:
1106 return "ANTHROPIC"
1107 }
1108}