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