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