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