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, provider.LMStudioProviderID:
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-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, provider.LMStudioProviderID,
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 || other == provider.LMStudioProviderID {
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 || provName == provider.LMStudioProviderID {
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 if provName == provider.LMStudioProviderID && LMStudioConfigured() {
445 // Restore transient metadata when resuming a local model after restart.
446 _ = RefreshLMStudioModels(context.Background())
447 }
448 resolvedModel, err := provider.FindModel(provName, model)
449 if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID) {
450 // Local providers are intentionally open-catalogue: any model id the
451 // configured server understands is valid, even if it is not cached.
452 resolvedModel = provider.Model{
453 Provider: provName,
454 ID: model,
455 DisplayName: model,
456 ContextWindow: 128000,
457 MaxOutput: 16384,
458 BaseURL: args.BaseURL,
459 Source: provName,
460 }
461 if provName == provider.LMStudioProviderID {
462 resolvedModel.ContextWindow = 32768
463 resolvedModel.MaxOutput = 4096
464 }
465 err = nil
466 }
467 // Custom providers are open-catalogue like ollama: any model id the
468 // endpoint understands is valid. Use the provider-level base URL.
469 if err != nil {
470 if cfg, ok := provider.CustomProviders()[provName]; ok {
471 resolvedModel = provider.Model{
472 Provider: provName,
473 ID: model,
474 DisplayName: model,
475 BaseURL: cfg.BaseURL,
476 Source: "user",
477 }
478 err = nil
479 }
480 }
481 if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
482 // Fall back to the provider-level base URL when the model does
483 // not define its own endpoint.
484 resolvedModel.BaseURL = cfg.BaseURL
485 }
486 if err != nil {
487 // The model the user (or persisted config) asked for is no
488 // longer in the active catalogue — they probably removed it
489 // from their models.json or upgraded zot and the id changed.
490 // Refusing to launch is the wrong move: it strands the user
491 // with no way to even open the TUI and pick a new model.
492 // Fall back to the provider's default, warn on stderr, and,
493 // when the stale id came from the persisted config (not an
494 // explicit --model flag), repair the config so the warning
495 // doesn't repeat on every launch.
496
497 // Gateway providers can accept route-qualified ids that are not in
498 // zot's local catalog yet, for example OpenRouter's
499 // "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
500 // plain unknown values are likely typos and should still fall back.
501 if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
502 resolvedModel = provider.Model{
503 Provider: provName,
504 ID: model,
505 DisplayName: model,
506 ContextWindow: 1000000,
507 MaxOutput: 64000,
508 BaseURL: "",
509 Source: "gateway",
510 }
511 err = nil
512 // Don't repair config — the routed model id may be valid upstream.
513 } else {
514 fallback := defaultModelForProvider(provName)
515 fm, ferr := provider.FindModel(provName, fallback)
516 if ferr != nil {
517 // Even the provider default is gone (catastrophic
518 // catalogue trim). Last resort: any model on this
519 // provider, then the global DefaultModel.
520 if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
521 fm = candidates[0]
522 ferr = nil
523 } else {
524 fm = provider.DefaultModel
525 ferr = nil
526 }
527 }
528 fmt.Fprintf(os.Stderr,
529 "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
530 model, fm.ID)
531 if args.Model == "" && cfg.Model == model {
532 cfg.Model = fm.ID
533 _ = SaveConfig(cfg)
534 }
535 resolvedModel = fm
536 model = fm.ID
537 }
538 }
539
540 explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
541
542 // If the model defines a base URL (e.g. local ollama) and the
543 // user didn't pass --base-url, use the model's URL. For ollama,
544 // keep http://localhost:11434 as a fallback only after the model
545 // metadata has had a chance to provide a custom baseUrl.
546 if args.BaseURL == "" && resolvedModel.BaseURL != "" && (provName != provider.LMStudioProviderID || resolvedModel.Source == "user") {
547 // LM Studio discovery can retain an older snapshot on failure.
548 // Use its currently registered endpoint below, never a stale URL
549 // with the credentials for a newly registered server.
550 args.BaseURL = resolvedModel.BaseURL
551 }
552 if args.BaseURL == "" && provName == "ollama" {
553 args.BaseURL = "http://localhost:11434"
554 }
555 if args.BaseURL == "" && provName == provider.LMStudioProviderID {
556 root, _, configErr := resolveLMStudioConfig(context.Background(), apiKeyCommandExecute)
557 if configErr != nil {
558 return Resolved{}, configErr
559 }
560 if root == "" {
561 return Resolved{}, fmt.Errorf("LM Studio requires a server URL registered through /login or an explicit --base-url")
562 }
563 args.BaseURL = root + "/v1"
564 }
565 if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
566 managementURL, _, configErr := ResolveLlamaCPPConfig()
567 if configErr != nil {
568 return Resolved{}, configErr
569 }
570 var baseErr error
571 args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
572 if baseErr != nil {
573 return Resolved{}, baseErr
574 }
575 }
576
577 // Insecure TLS is intentionally scoped to explicit custom endpoints.
578 // Built-in provider base URLs, auth calls, and model discovery keep normal
579 // certificate verification even when --insecure is present.
580 insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
581
582 // If the model has a base URL, credentials are optional (local
583 // models like ollama don't need real API keys).
584 if resolvedModel.BaseURL != "" && credErr != nil {
585 cred = "ollama"
586 credErr = nil
587 requireCred = false
588 }
589
590 if credErr != nil && requireCred {
591 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
592 credErr, envVarName(provName))
593 }
594
595 sandbox := tools.NewSandbox(args.CWD)
596 if cfg.JailByDefault != nil && *cfg.JailByDefault {
597 sandbox.Lock()
598 }
599 if args.PermissionSet != nil {
600 sandbox.SetPermissions(args.PermissionSet)
601 }
602 reg := buildConfiguredToolRegistry(args, args.CWD, sandbox, cfg, runtime.GOOS)
603 if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() {
604 reg = withOpenRouterServerTools(reg)
605 }
606
607 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
608
609 // Skill discovery: scan project + global locations + built-in
610 // skills shipped with the binary. If any are found, register
611 // the on-demand `skill` loader tool plus a system-prompt
612 // manifest so the model knows what's available.
613 //
614 // --no-skill bypasses the entire mechanism: no manifest in the
615 // system prompt, no `skill` tool in the registry. Useful for a
616 // clean-room run with zero extra context biasing the model.
617 var (
618 discovered []*skills.Skill
619 skillTool *skills.Tool
620 skillAddendum string
621 skillDiagnostics []string
622 )
623 if !args.NoSkill {
624 homeDir, _ := os.UserHomeDir()
625 var sources []skills.Source
626 if args.WithSkills {
627 for _, loc := range skills.SearchSources(ZotHome(), args.CWD, homeDir) {
628 sources = append(sources, loc)
629 }
630 }
631 if !args.NoExt || len(args.Exts) > 0 {
632 extSources, errs := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt)
633 sources = append(extSources, sources...)
634 for _, err := range errs {
635 skillDiagnostics = append(skillDiagnostics, err.Error())
636 }
637 }
638 var errs []error
639 discovered, errs = skills.DiscoverSources(sources, true)
640 for _, err := range errs {
641 skillDiagnostics = append(skillDiagnostics, err.Error())
642 }
643 if len(discovered) > 0 {
644 skillTool = skills.NewTool(discovered)
645 reg[skillTool.Name()] = skillTool
646 skillAddendum = skills.SystemPromptAddendum(discovered)
647 }
648 }
649 _ = skillTool
650
651 summaries := toolSummaries(reg, args)
652
653 var contextFiles []ContextFile
654 if !args.NoContextFiles {
655 contextFiles = loadAgentsContext(args.CWD, ZotHome())
656 }
657 append_ := append([]string(nil), args.AppendSystemPrompt...)
658 if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
659 append_ = append(append_, agentsAddendum)
660 }
661 if skillAddendum != "" {
662 append_ = append(append_, skillAddendum)
663 }
664 if AutoSwarmEnabled() {
665 append_ = append(append_, AutoSwarmSystemAddendum)
666 }
667
668 // Custom system prompt resolution order:
669 // 1. --system-prompt flag (highest priority; ad-hoc per run)
670 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
671 // 3. built-in default (defaultIdentity + defaultGuidelines)
672 custom := args.SystemPrompt
673 customSet := args.SystemPromptSet || custom != ""
674 if !customSet {
675 custom = readUserSystemPrompt(ZotHome())
676 customSet = custom != ""
677 }
678
679 sys := BuildSystemPrompt(SystemPromptOpts{
680 CWD: args.CWD,
681 Tools: summaries,
682 Custom: custom,
683 CustomSet: customSet,
684 Append: append_,
685 ZotDocsDir: docsDir,
686 })
687
688 reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
689 temperature := args.Temperature
690 if temperature == nil {
691 temperature = cfg.Temperature
692 }
693
694 max := args.MaxSteps // 0 = unlimited
695
696 return Resolved{
697 Provider: provName,
698 Model: model,
699 Credential: cred,
700 AuthMethod: method,
701 AccountID: accountID,
702 BaseURL: args.BaseURL,
703 InsecureTLS: insecureTLS,
704 CWD: args.CWD,
705 Reasoning: reasoning,
706 Temperature: temperature,
707 ToolRegistry: reg,
708 ToolSummary: summaries,
709 SystemPrompt: sys,
710 MaxSteps: max,
711 MaxOutput: resolvedModel.MaxOutput,
712 Sandbox: sandbox,
713 SkillTool: skillTool,
714 SkillDiagnostics: skillDiagnostics,
715 ContextFiles: contextFiles,
716 systemAppend: append_,
717 systemCustom: custom,
718 systemCustomSet: customSet,
719 toolDescriptions: descMapFromSummaries(summaries),
720 }, nil
721}
722
723// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
724// trimmed contents, or "" when the file is missing / unreadable /
725// empty. Errors are intentionally swallowed: the file is optional,
726// and any failure to read it should fall back to the built-in
727// default system prompt rather than crash the run.
728func readUserSystemPrompt(zotHome string) string {
729 if zotHome == "" {
730 return ""
731 }
732 path := filepath.Join(zotHome, "SYSTEM.md")
733 raw, err := os.ReadFile(path)
734 if err != nil {
735 return ""
736 }
737 return strings.TrimSpace(string(raw))
738}
739
740// loadAgentsContext loads optional AGENTS.md instruction files. No
741// default file is created or required: zot only includes files that
742// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
743// followed by project instructions from the filesystem root down to cwd.
744func loadAgentsContext(cwd, zotHome string) []ContextFile {
745 var files []ContextFile
746 seen := map[string]bool{}
747 add := func(path string) {
748 if path == "" {
749 return
750 }
751 abs, err := filepath.Abs(path)
752 if err == nil {
753 path = abs
754 }
755 if seen[path] {
756 return
757 }
758 raw, err := os.ReadFile(path)
759 if err != nil {
760 return
761 }
762 content := strings.TrimSpace(string(raw))
763 if content == "" {
764 return
765 }
766 seen[path] = true
767 files = append(files, ContextFile{Path: path, Content: content})
768 }
769 addFirstFromDir := func(dir string) {
770 if dir == "" {
771 return
772 }
773 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
774 path := filepath.Join(dir, name)
775 if _, err := os.Stat(path); err == nil {
776 add(path)
777 return
778 }
779 }
780 }
781
782 addFirstFromDir(zotHome)
783
784 if cwd != "" {
785 abs, err := filepath.Abs(cwd)
786 if err == nil {
787 cwd = abs
788 }
789 var dirs []string
790 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
791 dirs = append(dirs, dir)
792 parent := filepath.Dir(dir)
793 if parent == dir {
794 break
795 }
796 }
797 for i := len(dirs) - 1; i >= 0; i-- {
798 addFirstFromDir(dirs[i])
799 }
800 }
801
802 return files
803}
804
805func formatAgentsContext(files []ContextFile) string {
806 if len(files) == 0 {
807 return ""
808 }
809 var sb strings.Builder
810 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")
811 for _, f := range files {
812 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
813 }
814 return strings.TrimSpace(sb.String())
815}
816
817// descMapFromSummaries indexes the human-readable descriptions for
818// the renderToolsSection rebuild path.
819func descMapFromSummaries(summaries []ToolSummary) map[string]string {
820 out := make(map[string]string, len(summaries))
821 for _, s := range summaries {
822 out[s.Name] = s.Description
823 }
824 return out
825}
826
827// NewClient returns a provider.Client for r, choosing the auth mode
828// based on r.AuthMethod. Panics if no credential is present; callers
829// must check HasCredential() first.
830func (r Resolved) NewClient() provider.Client {
831 if !r.HasCredential() {
832 panic("NewClient called without credential; check HasCredential first")
833 }
834 wrap := r.withHTTPClient
835 switch r.Provider {
836 case "ollama":
837 return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
838 case provider.LlamaCPPProviderID, provider.LMStudioProviderID:
839 credential := r.Credential
840 if credential == "local" {
841 credential = ""
842 }
843 return wrap(provider.NewOpenAI(credential, r.BaseURL))
844 case "kimi":
845 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
846 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
847 inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
848 if r.AuthMethod == "oauth" {
849 return r.wrapWithRefresh(inner)
850 }
851 return inner
852 case "moonshotai":
853 return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
854 case "moonshotai-cn":
855 return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
856 case "deepseek":
857 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
858 case "openai":
859 return provider.NewModelRouter("openai",
860 wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
861 map[string]provider.Client{
862 provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
863 })
864 case "openai-codex":
865 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
866 return r.wrapWithRefresh(inner)
867 case "openai-responses":
868 // Public OpenAI Responses API (api.openai.com/v1/responses) via
869 // API key. Separate provider from `openai` (Chat Completions) and
870 // from `openai-codex` (ChatGPT subscription OAuth).
871 return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
872 case "google":
873 return wrap(provider.NewGemini(r.Credential, r.BaseURL))
874 case "cerebras":
875 return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
876 case "groq":
877 return wrap(provider.NewGroq(r.Credential, r.BaseURL))
878 case "xai":
879 inner := r.newXAIClient(r.Credential)
880 if r.AuthMethod == "oauth" {
881 return r.wrapWithRefresh(inner)
882 }
883 return inner
884 case "together":
885 return wrap(provider.NewTogether(r.Credential, r.BaseURL))
886 case "huggingface":
887 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
888 case "openrouter":
889 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
890 case "gondola":
891 return wrap(provider.NewGondola(r.Credential, r.BaseURL))
892 case "zai":
893 return wrap(provider.NewZAI(r.Credential, r.BaseURL))
894 case "xiaomi":
895 return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
896 case "xiaomi-token-plan-ams":
897 return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
898 case "xiaomi-token-plan-cn":
899 return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
900 case "xiaomi-token-plan-sgp":
901 return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
902 case "opencode":
903 return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
904 case "opencode-go":
905 return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
906 case "minimax":
907 return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
908 case "minimax-cn":
909 return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
910 case "fireworks":
911 return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
912 case "vercel-ai-gateway":
913 return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
914 case "mistral":
915 return wrap(provider.NewMistral(r.Credential, r.BaseURL))
916 case "amazon-bedrock":
917 return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
918 case "google-vertex":
919 return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
920 case "azure-openai-responses":
921 return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
922 case "github-copilot":
923 return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
924 case "cloudflare-workers-ai":
925 return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
926 case "cloudflare-ai-gateway":
927 return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
928 default:
929 // Custom providers: choose wire format from the models.json api field.
930 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
931 switch cfg.API {
932 case provider.APIResponses:
933 return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
934 case "anthropic":
935 return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
936 default: // "openai"
937 return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
938 }
939 }
940 if r.AuthMethod == "oauth" {
941 inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
942 return r.wrapWithRefresh(inner)
943 }
944 return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
945 }
946}
947
948func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
949 if !r.InsecureTLS {
950 return c
951 }
952 return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
953}
954
955func (r Resolved) newXAIClient(token string) provider.Client {
956 return provider.NewModelRouter("xai",
957 r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
958 map[string]provider.Client{
959 provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
960 })
961}
962
963// wrapWithRefresh wraps an OAuth client so the access token is
964// refreshed automatically before each API call. Without this, long
965// sessions (hours) silently fail when the 1-hour token expires.
966func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
967 provName := r.Provider
968 tokenProvider := provName
969 if provName == "openai-codex" {
970 tokenProvider = "openai"
971 }
972 baseURL := r.BaseURL
973 accountID := r.AccountID
974
975 refreshFn := func(ctx context.Context) (string, error) {
976 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
977 if err != nil {
978 return "", err
979 }
980 return tok.AccessToken, nil
981 }
982
983 factory := func(token string) provider.Client {
984 switch provName {
985 case "openai-codex":
986 return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
987 case "kimi":
988 // anthropic-messages on api.kimi.com/coding.
989 return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
990 case "xai":
991 return r.newXAIClient(token)
992 default:
993 return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
994 }
995 }
996
997 return provider.NewRefreshingClient(inner, refreshFn, factory)
998}
999
1000// UseSandbox replaces the sandbox pointer that every tool in r's
1001// registry references. Used to keep the /jail state stable across
1002// agent rebuilds (e.g. /login, /model switching providers).
1003func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1004 if s == nil || r == nil {
1005 return
1006 }
1007 if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1008 s.Permissions = r.Sandbox.Permissions
1009 }
1010 r.Sandbox = s
1011 for name, t := range r.ToolRegistry {
1012 switch v := t.(type) {
1013 case *tools.ReadTool:
1014 v.Sandbox = s
1015 case *tools.WriteTool:
1016 v.Sandbox = s
1017 case *tools.EditTool:
1018 v.Sandbox = s
1019 case *tools.BashTool:
1020 v.Sandbox = s
1021 case *tools.PowerShellTool:
1022 v.Sandbox = s
1023 case *tools.GlobTool:
1024 v.Sandbox = s
1025 }
1026 _ = name
1027 }
1028}
1029
1030// NewAgent constructs a core.Agent from r. Requires a credential.
1031func (r Resolved) NewAgent() *core.Agent {
1032 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1033 a.MaxSteps = r.MaxSteps
1034 a.MaxTokens = r.MaxOutput
1035 a.Reasoning = r.Reasoning
1036 a.Temperature = r.Temperature
1037 if hasOpenRouterServerTools(r.ToolRegistry) {
1038 a.MaxToolCalls = openRouterServerToolCallLimit()
1039 }
1040 return a
1041}
1042
1043// openRouterServerToolCallLimit returns the configured call limit from
1044// config.json, falling back to the built-in default (4).
1045func openRouterServerToolCallLimit() int {
1046 cfg, err := LoadConfig()
1047 if err != nil {
1048 return tools.OpenRouterServerToolCallLimit
1049 }
1050 if cfg.OpenRouterServerToolCallLimit != nil && *cfg.OpenRouterServerToolCallLimit > 0 {
1051 return *cfg.OpenRouterServerToolCallLimit
1052 }
1053 return tools.OpenRouterServerToolCallLimit
1054}
1055
1056// OpenRouterServerToolsEnabled reports the persisted preference.
1057// nil/missing and unreadable configuration fail closed to disabled.
1058func OpenRouterServerToolsEnabled() bool {
1059 cfg, err := LoadConfig()
1060 if err != nil {
1061 return false
1062 }
1063 return cfg.OpenRouterServerToolsEnabled != nil && *cfg.OpenRouterServerToolsEnabled
1064}
1065
1066func isOpenRouterPresetModel(model string) bool {
1067 return strings.Contains(strings.ToLower(model), "@preset/")
1068}
1069
1070func hasOpenRouterServerTools(reg core.Registry) bool {
1071 for name := range reg {
1072 if tools.IsOpenRouterServerTool(name) {
1073 return true
1074 }
1075 }
1076 return false
1077}
1078
1079func withOpenRouterServerTools(reg core.Registry) core.Registry {
1080 out := core.Registry{}
1081 for name, t := range reg {
1082 out[name] = t
1083 }
1084 for _, t := range tools.OpenRouterServerTools() {
1085 out[t.Name()] = t
1086 }
1087 return out
1088}
1089
1090func buildConfiguredToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, cfg Config, goos string) core.Registry {
1091 reg := buildToolRegistry(args, cwd, sandbox)
1092 if goos == "windows" && !args.NoTools && len(args.Tools) == 0 && cfg.PowerShellEnabled != nil && *cfg.PowerShellEnabled {
1093 reg["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1094 }
1095 return reg
1096}
1097
1098func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
1099 if args.NoTools {
1100 return core.Registry{}
1101 }
1102 all := map[string]core.Tool{
1103 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1104 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
1105 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
1106 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1107 "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1108 }
1109 reg := core.Registry{}
1110 if len(args.Tools) == 0 {
1111 for _, t := range all {
1112 reg[t.Name()] = t
1113 }
1114 return reg
1115 }
1116 // PowerShell is explicitly selected, never added to the default tool set.
1117 all["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1118 for _, name := range args.Tools {
1119 if t, ok := all[name]; ok {
1120 reg[name] = t
1121 }
1122 }
1123 return reg
1124}
1125
1126func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1127 order := []string{"read", "write", "edit", "bash", "powershell", "glob"}
1128 var out []ToolSummary
1129 for _, name := range order {
1130 if t, ok := reg[name]; ok {
1131 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1132 }
1133 }
1134 return out
1135}
1136
1137func firstNonEmpty(vals ...string) string {
1138 for _, v := range vals {
1139 if v != "" {
1140 return v
1141 }
1142 }
1143 return ""
1144}
1145
1146func kimiCodeHeaders() map[string]string {
1147 host, _ := os.Hostname()
1148 if host == "" {
1149 host = "unknown"
1150 }
1151 deviceID := ""
1152 if home, err := os.UserHomeDir(); err == nil {
1153 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1154 deviceID = strings.TrimSpace(string(b))
1155 }
1156 }
1157 if deviceID == "" {
1158 deviceID = "zot"
1159 }
1160 return map[string]string{
1161 "User-Agent": "KimiCLI/1.41.0",
1162 "X-Msh-Platform": "kimi_cli",
1163 "X-Msh-Version": "1.41.0",
1164 "X-Msh-Device-Name": host,
1165 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1166 "X-Msh-Os-Version": runtime.GOOS,
1167 "X-Msh-Device-Id": deviceID,
1168 }
1169}
1170
1171func envVarName(provider string) string {
1172 switch provider {
1173 case "openai", "openai-codex", "openai-responses":
1174 return "OPENAI"
1175 case "kimi":
1176 return "KIMI"
1177 case "deepseek":
1178 return "DEEPSEEK"
1179 case "google":
1180 return "GEMINI"
1181 case "ollama":
1182 return "OLLAMA"
1183 case "llama.cpp":
1184 return "LLAMA"
1185 case "moonshotai", "moonshotai-cn":
1186 return "MOONSHOT"
1187 case "groq":
1188 return "GROQ"
1189 case "xai":
1190 return "XAI"
1191 case "cerebras":
1192 return "CEREBRAS"
1193 case "together":
1194 return "TOGETHER"
1195 case "huggingface":
1196 return "HF"
1197 case "openrouter":
1198 return "OPENROUTER"
1199 case "gondola":
1200 return "GONDOLA"
1201 case "mistral":
1202 return "MISTRAL"
1203 case "zai":
1204 return "ZAI"
1205 case "xiaomi":
1206 return "XIAOMI"
1207 case "xiaomi-token-plan-ams":
1208 return "XIAOMI_TOKEN_PLAN_AMS"
1209 case "xiaomi-token-plan-cn":
1210 return "XIAOMI_TOKEN_PLAN_CN"
1211 case "xiaomi-token-plan-sgp":
1212 return "XIAOMI_TOKEN_PLAN_SGP"
1213 case "minimax":
1214 return "MINIMAX"
1215 case "minimax-cn":
1216 return "MINIMAX_CN"
1217 case "fireworks":
1218 return "FIREWORKS"
1219 case "vercel-ai-gateway":
1220 return "AI_GATEWAY"
1221 case "opencode", "opencode-go":
1222 return "OPENCODE"
1223 case "github-copilot":
1224 return "COPILOT_GITHUB_TOKEN"
1225 case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1226 return "CLOUDFLARE"
1227 case "amazon-bedrock":
1228 return "AWS"
1229 case "google-vertex":
1230 return "GOOGLE_CLOUD"
1231 case "azure-openai-responses":
1232 return "AZURE_OPENAI"
1233 default:
1234 return "ANTHROPIC"
1235 }
1236}