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