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