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