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