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/internal/agent/tools"
13 "github.com/patriceckhart/zot/internal/core"
14 "github.com/patriceckhart/zot/internal/provider"
15 "github.com/patriceckhart/zot/internal/skills"
16)
17
18// Resolved is the effective configuration after merging CLI, config, defaults.
19type Resolved struct {
20 Provider string
21 Model string
22 Credential string // api key or oauth access token
23 AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
24 AccountID string // ChatGPT account id (for openai oauth), "" otherwise
25 BaseURL string
26 CWD string
27 Reasoning string
28
29 ToolRegistry core.Registry
30 ToolSummary []ToolSummary
31 SystemPrompt string
32 MaxSteps int
33 Sandbox *tools.Sandbox
34
35 // SkillTool is the on-demand skill loader registered with the
36 // agent's tool registry, or nil if no SKILL.md files were
37 // discovered. Exposed so the tui can list / preview skills.
38 SkillTool *skills.Tool
39
40 // Bookkeeping for MergeExtensionTools. Captured at Resolve time
41 // so the system prompt can be rebuilt later without re-running
42 // resolve.
43 systemAppend []string
44 systemCustom string
45 toolDescriptions map[string]string
46}
47
48// HasCredential reports whether a credential was resolved.
49func (r Resolved) HasCredential() bool { return r.Credential != "" }
50
51// MergeExtensionTools folds every tool registered by an extension
52// into r's ToolRegistry and re-renders the system prompt's tool
53// summary so the model sees both built-in and extension tools.
54//
55// Idempotent: calling twice with the same manager state has no
56// effect on the second pass (existing names are preserved). Built-in
57// tools always win on conflict.
58func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
59 if mgr == nil {
60 return
61 }
62 infos := mgr.Tools()
63 if len(infos) == 0 {
64 return
65 }
66 changed := false
67 for _, info := range infos {
68 if _, exists := r.ToolRegistry[info.Name]; exists {
69 continue
70 }
71 r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
72 changed = true
73 }
74 if !changed {
75 return
76 }
77 // Re-render the system prompt with the merged tool list. Skill
78 // addendum is preserved by walking the existing append slice.
79 append_ := r.systemAppend
80 r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
81 CWD: r.CWD,
82 Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
83 Custom: r.systemCustom,
84 Append: append_,
85 ZotDocsDir: filepath.Join(ZotHome(), "docs"),
86 })
87}
88
89// ExtensionToolSource is the slice of the extension manager that
90// MergeExtensionTools needs. Lives here as an interface so the
91// build package doesn't import internal/agent/extensions (which
92// imports core, which imports... avoid the cycle).
93type ExtensionToolSource interface {
94 Tools() []ExtensionToolInfo
95 NewExtensionTool(info ExtensionToolInfo) core.Tool
96}
97
98// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
99// ExtensionToolSource here without importing the extensions
100// package. The cli wires a tiny adapter to bridge them.
101type ExtensionToolInfo struct {
102 Extension string
103 Name string
104 Description string
105 Schema []byte
106}
107
108// toolSummariesFromRegistry rebuilds the system-prompt tool list
109// from a (possibly extended) registry, using cached descriptions for
110// the human-readable summary text.
111func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
112 out := make([]ToolSummary, 0, len(reg))
113 for name, t := range reg {
114 desc := t.Description()
115 if d, ok := cached[name]; ok && d != "" {
116 desc = d
117 }
118 out = append(out, ToolSummary{Name: name, Description: desc})
119 }
120 return out
121}
122
123// defaultModelForProvider returns the model id zot prefers when the
124// caller didn't pick one. Mirrors the per-provider switch used at
125// multiple points in Resolve; centralised so the unknown-model
126// recovery path and the no-model-configured path can't drift.
127//
128// Returns the empty string for "ollama", which has no built-in
129// default — the caller is expected to special-case ollama and
130// error or use whatever the user passed.
131func defaultModelForProvider(prov string) string {
132 switch prov {
133 case "openai":
134 return "gpt-5"
135 case "openai-codex":
136 return "gpt-5.5"
137 case "kimi":
138 return "kimi-for-coding"
139 case "deepseek":
140 return "deepseek-v4-pro"
141 case "google":
142 return "gemini-2.5-pro"
143 case "ollama":
144 return ""
145 default:
146 return provider.DefaultModel.ID
147 }
148}
149
150// Resolve merges args, config, and env into a Resolved set.
151//
152// Unlike the earlier version, Resolve NEVER returns an error for
153// missing credentials: the TUI can start without them and launch a
154// login flow. requireCred controls whether missing credentials are a
155// hard error (used by print/json modes).
156func Resolve(args Args, requireCred bool) (Resolved, error) {
157 cfg, _ := LoadConfig()
158
159 // User-requested provider (explicit > config > default).
160 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
161 if provName != "anthropic" && provName != "openai" && provName != "openai-codex" && provName != "kimi" && provName != "deepseek" && provName != "google" && provName != "ollama" {
162 // Unknown provider (maybe removed or renamed). Fall back to
163 // the first provider that has credentials, or anthropic.
164 provName = "anthropic"
165 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
166 provName = "openai"
167 }
168 if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil {
169 provName = "openai-codex"
170 }
171 if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
172 provName = "kimi"
173 }
174 if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil {
175 provName = "deepseek"
176 }
177 if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
178 provName = "anthropic"
179 }
180 // Reset the saved config so this doesn't keep happening.
181 cfg.Provider = provName
182 cfg.Model = ""
183 _ = SaveConfig(cfg)
184 }
185
186 var (
187 cred string
188 method string
189 accountID string
190 credErr error
191 )
192 if provName == "ollama" {
193 if args.BaseURL == "" {
194 args.BaseURL = "http://localhost:11434"
195 }
196 cred = firstNonEmpty(args.APIKey, "ollama")
197 method = "apikey"
198 } else {
199 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
200 }
201
202 // If the user did NOT explicitly pick a provider and the default one
203 // has no credentials, auto-fall-back to whichever provider is actually
204 // logged in. That way running plain `zot` after `/login` (any provider)
205 // never shows a "not logged in" banner.
206 userPickedProvider := args.Provider != ""
207 if credErr != nil && !userPickedProvider && provName != "ollama" {
208 for _, other := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google"} {
209 if other == provName {
210 continue
211 }
212 if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
213 provName = other
214 cred, method, accountID, credErr = c, m, a, err
215 break
216 }
217 }
218 }
219
220 model := firstNonEmpty(args.Model, cfg.Model)
221 if model == "" {
222 switch provName {
223 case "openai":
224 model = "gpt-5"
225 case "openai-codex":
226 model = "gpt-5.5"
227 case "kimi":
228 model = "kimi-for-coding"
229 case "deepseek":
230 model = "deepseek-v4-pro"
231 case "google":
232 model = "gemini-2.5-pro"
233 case "ollama":
234 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
235 default:
236 model = provider.DefaultModel.ID
237 }
238 }
239 // If the resolved model belongs to a different provider (e.g. config
240 // says gpt-5 but we fell back to anthropic), pick that provider's default.
241 if provName != "ollama" {
242 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
243 switch provName {
244 case "openai":
245 model = "gpt-5"
246 case "openai-codex":
247 model = "gpt-5.5"
248 case "kimi":
249 model = "kimi-for-coding"
250 case "deepseek":
251 model = "deepseek-v4-pro"
252 case "google":
253 model = "gemini-2.5-pro"
254 default:
255 model = provider.DefaultModel.ID
256 }
257 }
258 }
259 resolvedModel, err := provider.FindModel(provName, model)
260 if err != nil && provName == "ollama" {
261 // ollama is intentionally open-catalogue: any model id the
262 // local server understands is valid, even if not in the
263 // baked-in catalog.
264 resolvedModel = provider.Model{
265 Provider: "ollama",
266 ID: model,
267 DisplayName: model,
268 ContextWindow: 32768,
269 MaxOutput: 8192,
270 BaseURL: args.BaseURL,
271 Source: "ollama",
272 }
273 err = nil
274 }
275 if err != nil {
276 // The model the user (or persisted config) asked for is no
277 // longer in the active catalogue — they probably removed it
278 // from their models.json or upgraded zot and the id changed.
279 // Refusing to launch is the wrong move: it strands the user
280 // with no way to even open the TUI and pick a new model.
281 // Fall back to the provider's default, warn on stderr, and,
282 // when the stale id came from the persisted config (not an
283 // explicit --model flag), repair the config so the warning
284 // doesn't repeat on every launch.
285 fallback := defaultModelForProvider(provName)
286 fm, ferr := provider.FindModel(provName, fallback)
287 if ferr != nil {
288 // Even the provider default is gone (catastrophic
289 // catalogue trim). Last resort: any model on this
290 // provider, then the global DefaultModel.
291 if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
292 fm = candidates[0]
293 ferr = nil
294 } else {
295 fm = provider.DefaultModel
296 ferr = nil
297 }
298 }
299 fmt.Fprintf(os.Stderr,
300 "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
301 model, fm.ID)
302 if args.Model == "" && cfg.Model == model {
303 cfg.Model = fm.ID
304 _ = SaveConfig(cfg)
305 }
306 resolvedModel = fm
307 model = fm.ID
308 }
309
310 // If the model defines a base URL (e.g. local ollama) and the
311 // user didn't pass --base-url, use the model's URL.
312 if args.BaseURL == "" && resolvedModel.BaseURL != "" {
313 args.BaseURL = resolvedModel.BaseURL
314 }
315
316 // If the model has a base URL, credentials are optional (local
317 // models like ollama don't need real API keys).
318 if resolvedModel.BaseURL != "" && credErr != nil {
319 cred = "ollama"
320 credErr = nil
321 requireCred = false
322 }
323
324 if credErr != nil && requireCred {
325 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
326 credErr, envVarName(provName))
327 }
328
329 sandbox := tools.NewSandbox(args.CWD)
330 reg := buildToolRegistry(args, args.CWD, sandbox)
331
332 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
333
334 // Skill discovery: scan project + global locations + built-in
335 // skills shipped with the binary. If any are found, register
336 // the on-demand `skill` loader tool plus a system-prompt
337 // manifest so the model knows what's available.
338 //
339 // --no-skill bypasses the entire mechanism: no manifest in the
340 // system prompt, no `skill` tool in the registry. Useful for a
341 // clean-room run with zero extra context biasing the model.
342 var (
343 discovered []*skills.Skill
344 skillTool *skills.Tool
345 skillAddendum string
346 )
347 if !args.NoSkill {
348 homeDir, _ := os.UserHomeDir()
349 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
350 if len(discovered) > 0 {
351 skillTool = skills.NewTool(discovered)
352 reg[skillTool.Name()] = skillTool
353 skillAddendum = skills.SystemPromptAddendum(discovered)
354 }
355 }
356 _ = skillTool
357
358 summaries := toolSummaries(reg, args)
359
360 append_ := append([]string(nil), args.AppendSystemPrompt...)
361 if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
362 append_ = append(append_, agentsAddendum)
363 }
364 if skillAddendum != "" {
365 append_ = append(append_, skillAddendum)
366 }
367
368 // Custom system prompt resolution order:
369 // 1. --system-prompt flag (highest priority; ad-hoc per run)
370 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
371 // 3. built-in default (defaultIdentity + defaultGuidelines)
372 custom := args.SystemPrompt
373 if custom == "" {
374 custom = readUserSystemPrompt(ZotHome())
375 }
376
377 sys := BuildSystemPrompt(SystemPromptOpts{
378 CWD: args.CWD,
379 Tools: summaries,
380 Custom: custom,
381 Append: append_,
382 ZotDocsDir: docsDir,
383 })
384
385 reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
386
387 max := args.MaxSteps // 0 = unlimited
388
389 return Resolved{
390 Provider: provName,
391 Model: model,
392 Credential: cred,
393 AuthMethod: method,
394 AccountID: accountID,
395 BaseURL: args.BaseURL,
396 CWD: args.CWD,
397 Reasoning: reasoning,
398 ToolRegistry: reg,
399 ToolSummary: summaries,
400 SystemPrompt: sys,
401 MaxSteps: max,
402 Sandbox: sandbox,
403 SkillTool: skillTool,
404 systemAppend: append_,
405 systemCustom: custom,
406 toolDescriptions: descMapFromSummaries(summaries),
407 }, nil
408}
409
410// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
411// trimmed contents, or "" when the file is missing / unreadable /
412// empty. Errors are intentionally swallowed: the file is optional,
413// and any failure to read it should fall back to the built-in
414// default system prompt rather than crash the run.
415func readUserSystemPrompt(zotHome string) string {
416 if zotHome == "" {
417 return ""
418 }
419 path := filepath.Join(zotHome, "SYSTEM.md")
420 raw, err := os.ReadFile(path)
421 if err != nil {
422 return ""
423 }
424 return strings.TrimSpace(string(raw))
425}
426
427// readAgentsContext loads optional AGENTS.md instruction files. No
428// default file is created or required: zot only includes files that
429// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
430// followed by project instructions from the top-most parent down to cwd.
431func readAgentsContext(cwd, zotHome string) string {
432 type contextFile struct {
433 path string
434 content string
435 }
436 var files []contextFile
437 seen := map[string]bool{}
438 add := func(path string) {
439 if path == "" {
440 return
441 }
442 abs, err := filepath.Abs(path)
443 if err == nil {
444 path = abs
445 }
446 if seen[path] {
447 return
448 }
449 raw, err := os.ReadFile(path)
450 if err != nil {
451 return
452 }
453 content := strings.TrimSpace(string(raw))
454 if content == "" {
455 return
456 }
457 seen[path] = true
458 files = append(files, contextFile{path: path, content: content})
459 }
460 addFirstFromDir := func(dir string) {
461 if dir == "" {
462 return
463 }
464 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
465 path := filepath.Join(dir, name)
466 if _, err := os.Stat(path); err == nil {
467 add(path)
468 return
469 }
470 }
471 }
472
473 addFirstFromDir(zotHome)
474
475 if cwd != "" {
476 abs, err := filepath.Abs(cwd)
477 if err == nil {
478 cwd = abs
479 }
480 var dirs []string
481 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
482 dirs = append(dirs, dir)
483 parent := filepath.Dir(dir)
484 if parent == dir {
485 break
486 }
487 }
488 for i := len(dirs) - 1; i >= 0; i-- {
489 addFirstFromDir(dirs[i])
490 }
491 }
492
493 if len(files) == 0 {
494 return ""
495 }
496 var sb strings.Builder
497 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")
498 for _, f := range files {
499 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
500 }
501 return strings.TrimSpace(sb.String())
502}
503
504// descMapFromSummaries indexes the human-readable descriptions for
505// the renderToolsSection rebuild path.
506func descMapFromSummaries(summaries []ToolSummary) map[string]string {
507 out := make(map[string]string, len(summaries))
508 for _, s := range summaries {
509 out[s.Name] = s.Description
510 }
511 return out
512}
513
514// NewClient returns a provider.Client for r, choosing the auth mode
515// based on r.AuthMethod. Panics if no credential is present; callers
516// must check HasCredential() first.
517func (r Resolved) NewClient() provider.Client {
518 if !r.HasCredential() {
519 panic("NewClient called without credential; check HasCredential first")
520 }
521 switch r.Provider {
522 case "ollama":
523 return provider.NewOpenAI(r.Credential, r.BaseURL)
524 case "kimi":
525 inner := provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
526 if r.AuthMethod == "oauth" {
527 return r.wrapWithRefresh(inner)
528 }
529 return inner
530 case "deepseek":
531 return provider.NewDeepSeek(r.Credential, r.BaseURL)
532 case "openai":
533 return provider.NewOpenAI(r.Credential, r.BaseURL)
534 case "openai-codex":
535 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
536 return r.wrapWithRefresh(inner)
537 case "google":
538 // API-key only path. Gemini Generative Language API.
539 return provider.NewGemini(r.Credential, r.BaseURL)
540 default:
541 if r.AuthMethod == "oauth" {
542 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
543 return r.wrapWithRefresh(inner)
544 }
545 return provider.NewAnthropic(r.Credential, r.BaseURL)
546 }
547}
548
549// wrapWithRefresh wraps an OAuth client so the access token is
550// refreshed automatically before each API call. Without this, long
551// sessions (hours) silently fail when the 1-hour token expires.
552func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
553 provName := r.Provider
554 tokenProvider := provName
555 if provName == "openai-codex" {
556 tokenProvider = "openai"
557 }
558 baseURL := r.BaseURL
559 accountID := r.AccountID
560
561 refreshFn := func(ctx context.Context) (string, error) {
562 tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
563 if err != nil {
564 return "", err
565 }
566 return tok.AccessToken, nil
567 }
568
569 factory := func(token string) provider.Client {
570 switch provName {
571 case "openai-codex":
572 return provider.NewOpenAICodex(token, accountID, baseURL)
573 case "kimi":
574 return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders())
575 default:
576 return provider.NewAnthropicOAuth(token, baseURL)
577 }
578 }
579
580 return provider.NewRefreshingClient(inner, refreshFn, factory)
581}
582
583// UseSandbox replaces the sandbox pointer that every tool in r's
584// registry references. Used to keep the /jail state stable across
585// agent rebuilds (e.g. /login, /model switching providers).
586func (r *Resolved) UseSandbox(s *tools.Sandbox) {
587 if s == nil || r == nil {
588 return
589 }
590 r.Sandbox = s
591 for name, t := range r.ToolRegistry {
592 switch v := t.(type) {
593 case *tools.ReadTool:
594 v.Sandbox = s
595 case *tools.WriteTool:
596 v.Sandbox = s
597 case *tools.EditTool:
598 v.Sandbox = s
599 case *tools.BashTool:
600 v.Sandbox = s
601 }
602 _ = name
603 }
604}
605
606// NewAgent constructs a core.Agent from r. Requires a credential.
607func (r Resolved) NewAgent() *core.Agent {
608 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
609 a.MaxSteps = r.MaxSteps
610 a.Reasoning = r.Reasoning
611 return a
612}
613
614func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
615 if args.NoTools {
616 return core.Registry{}
617 }
618 all := map[string]core.Tool{
619 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
620 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
621 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
622 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
623 }
624 reg := core.Registry{}
625 if len(args.Tools) == 0 {
626 for _, t := range all {
627 reg[t.Name()] = t
628 }
629 return reg
630 }
631 for _, name := range args.Tools {
632 if t, ok := all[name]; ok {
633 reg[name] = t
634 }
635 }
636 return reg
637}
638
639func toolSummaries(reg core.Registry, args Args) []ToolSummary {
640 order := []string{"read", "write", "edit", "bash"}
641 var out []ToolSummary
642 for _, name := range order {
643 if t, ok := reg[name]; ok {
644 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
645 }
646 }
647 return out
648}
649
650func firstNonEmpty(vals ...string) string {
651 for _, v := range vals {
652 if v != "" {
653 return v
654 }
655 }
656 return ""
657}
658
659func kimiCodeHeaders() map[string]string {
660 host, _ := os.Hostname()
661 if host == "" {
662 host = "unknown"
663 }
664 deviceID := ""
665 if home, err := os.UserHomeDir(); err == nil {
666 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
667 deviceID = strings.TrimSpace(string(b))
668 }
669 }
670 if deviceID == "" {
671 deviceID = "zot"
672 }
673 return map[string]string{
674 "User-Agent": "KimiCLI/1.41.0",
675 "X-Msh-Platform": "kimi_cli",
676 "X-Msh-Version": "1.41.0",
677 "X-Msh-Device-Name": host,
678 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
679 "X-Msh-Os-Version": runtime.GOOS,
680 "X-Msh-Device-Id": deviceID,
681 }
682}
683
684func envVarName(provider string) string {
685 switch provider {
686 case "openai", "openai-codex":
687 return "OPENAI"
688 case "kimi":
689 return "KIMI"
690 case "deepseek":
691 return "DEEPSEEK"
692 case "google":
693 return "GEMINI"
694 case "ollama":
695 return "OLLAMA"
696 default:
697 return "ANTHROPIC"
698 }
699}