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