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// Resolve merges args, config, and env into a Resolved set.
122//
123// Unlike the earlier version, Resolve NEVER returns an error for
124// missing credentials: the TUI can start without them and launch a
125// login flow. requireCred controls whether missing credentials are a
126// hard error (used by print/json modes).
127func Resolve(args Args, requireCred bool) (Resolved, error) {
128 cfg, _ := LoadConfig()
129
130 // User-requested provider (explicit > config > default).
131 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
132 if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "google" && provName != "ollama" {
133 // Unknown provider (maybe removed or renamed). Fall back to
134 // the first provider that has credentials, or anthropic.
135 provName = "anthropic"
136 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
137 provName = "openai"
138 }
139 if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
140 provName = "kimi"
141 }
142 if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
143 provName = "anthropic"
144 }
145 // Reset the saved config so this doesn't keep happening.
146 cfg.Provider = provName
147 cfg.Model = ""
148 _ = SaveConfig(cfg)
149 }
150
151 var (
152 cred string
153 method string
154 accountID string
155 credErr error
156 )
157 if provName == "ollama" {
158 if args.BaseURL == "" {
159 args.BaseURL = "http://localhost:11434"
160 }
161 cred = firstNonEmpty(args.APIKey, "ollama")
162 method = "apikey"
163 } else {
164 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
165 }
166
167 // If the user did NOT explicitly pick a provider and the default one
168 // has no credentials, auto-fall-back to whichever provider is actually
169 // logged in. That way running plain `zot` after `/login` (any provider)
170 // never shows a "not logged in" banner.
171 userPickedProvider := args.Provider != ""
172 if credErr != nil && !userPickedProvider && provName != "ollama" {
173 for _, other := range []string{"anthropic", "openai", "kimi", "google"} {
174 if other == provName {
175 continue
176 }
177 if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
178 provName = other
179 cred, method, accountID, credErr = c, m, a, err
180 break
181 }
182 }
183 }
184
185 model := firstNonEmpty(args.Model, cfg.Model)
186 if model == "" {
187 switch provName {
188 case "openai":
189 model = "gpt-5"
190 case "kimi":
191 model = "kimi-for-coding"
192 case "google":
193 model = "gemini-2.5-pro"
194 case "ollama":
195 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
196 default:
197 model = provider.DefaultModel.ID
198 }
199 }
200 // If the resolved model belongs to a different provider (e.g. config
201 // says gpt-5 but we fell back to anthropic), pick that provider's default.
202 if provName != "ollama" {
203 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
204 switch provName {
205 case "openai":
206 model = "gpt-5"
207 case "kimi":
208 model = "kimi-for-coding"
209 case "google":
210 model = "gemini-2.5-pro"
211 default:
212 model = provider.DefaultModel.ID
213 }
214 }
215 }
216 resolvedModel, err := provider.FindModel(provName, model)
217 if err != nil && provName != "ollama" {
218 return Resolved{}, err
219 }
220 if err != nil && provName == "ollama" {
221 resolvedModel = provider.Model{
222 Provider: "ollama",
223 ID: model,
224 DisplayName: model,
225 ContextWindow: 32768,
226 MaxOutput: 8192,
227 BaseURL: args.BaseURL,
228 Source: "ollama",
229 }
230 }
231
232 // If the model defines a base URL (e.g. local ollama) and the
233 // user didn't pass --base-url, use the model's URL.
234 if args.BaseURL == "" && resolvedModel.BaseURL != "" {
235 args.BaseURL = resolvedModel.BaseURL
236 }
237
238 // If the model has a base URL, credentials are optional (local
239 // models like ollama don't need real API keys).
240 if resolvedModel.BaseURL != "" && credErr != nil {
241 cred = "ollama"
242 credErr = nil
243 requireCred = false
244 }
245
246 if credErr != nil && requireCred {
247 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
248 credErr, envVarName(provName))
249 }
250
251 sandbox := tools.NewSandbox(args.CWD)
252 reg := buildToolRegistry(args, args.CWD, sandbox)
253
254 // Skill discovery: scan project + global locations + built-in
255 // skills shipped with the binary. If any are found, register
256 // the on-demand `skill` loader tool plus a system-prompt
257 // manifest so the model knows what's available.
258 //
259 // --no-skill bypasses the entire mechanism: no manifest in the
260 // system prompt, no `skill` tool in the registry. Useful for a
261 // clean-room run with zero extra context biasing the model.
262 var (
263 discovered []*skills.Skill
264 skillTool *skills.Tool
265 skillAddendum string
266 )
267 if !args.NoSkill {
268 homeDir, _ := os.UserHomeDir()
269 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
270 if len(discovered) > 0 {
271 skillTool = skills.NewTool(discovered)
272 reg[skillTool.Name()] = skillTool
273 skillAddendum = skills.SystemPromptAddendum(discovered)
274 }
275 }
276 _ = skillTool
277
278 summaries := toolSummaries(reg, args)
279
280 append_ := append([]string(nil), args.AppendSystemPrompt...)
281 if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
282 append_ = append(append_, agentsAddendum)
283 }
284 if skillAddendum != "" {
285 append_ = append(append_, skillAddendum)
286 }
287
288 // Custom system prompt resolution order:
289 // 1. --system-prompt flag (highest priority; ad-hoc per run)
290 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
291 // 3. built-in default (defaultIdentity + defaultGuidelines)
292 custom := args.SystemPrompt
293 if custom == "" {
294 custom = readUserSystemPrompt(ZotHome())
295 }
296
297 sys := BuildSystemPrompt(SystemPromptOpts{
298 CWD: args.CWD,
299 Tools: summaries,
300 Custom: custom,
301 Append: append_,
302 })
303
304 reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
305
306 max := args.MaxSteps // 0 = unlimited
307
308 return Resolved{
309 Provider: provName,
310 Model: model,
311 Credential: cred,
312 AuthMethod: method,
313 AccountID: accountID,
314 BaseURL: args.BaseURL,
315 CWD: args.CWD,
316 Reasoning: reasoning,
317 ToolRegistry: reg,
318 ToolSummary: summaries,
319 SystemPrompt: sys,
320 MaxSteps: max,
321 Sandbox: sandbox,
322 SkillTool: skillTool,
323 systemAppend: append_,
324 systemCustom: custom,
325 toolDescriptions: descMapFromSummaries(summaries),
326 }, nil
327}
328
329// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
330// trimmed contents, or "" when the file is missing / unreadable /
331// empty. Errors are intentionally swallowed: the file is optional,
332// and any failure to read it should fall back to the built-in
333// default system prompt rather than crash the run.
334func readUserSystemPrompt(zotHome string) string {
335 if zotHome == "" {
336 return ""
337 }
338 path := filepath.Join(zotHome, "SYSTEM.md")
339 raw, err := os.ReadFile(path)
340 if err != nil {
341 return ""
342 }
343 return strings.TrimSpace(string(raw))
344}
345
346// readAgentsContext loads optional AGENTS.md instruction files. No
347// default file is created or required: zot only includes files that
348// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
349// followed by project instructions from the top-most parent down to cwd.
350func readAgentsContext(cwd, zotHome string) string {
351 type contextFile struct {
352 path string
353 content string
354 }
355 var files []contextFile
356 seen := map[string]bool{}
357 add := func(path string) {
358 if path == "" {
359 return
360 }
361 abs, err := filepath.Abs(path)
362 if err == nil {
363 path = abs
364 }
365 if seen[path] {
366 return
367 }
368 raw, err := os.ReadFile(path)
369 if err != nil {
370 return
371 }
372 content := strings.TrimSpace(string(raw))
373 if content == "" {
374 return
375 }
376 seen[path] = true
377 files = append(files, contextFile{path: path, content: content})
378 }
379 addFirstFromDir := func(dir string) {
380 if dir == "" {
381 return
382 }
383 for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
384 path := filepath.Join(dir, name)
385 if _, err := os.Stat(path); err == nil {
386 add(path)
387 return
388 }
389 }
390 }
391
392 addFirstFromDir(zotHome)
393
394 if cwd != "" {
395 abs, err := filepath.Abs(cwd)
396 if err == nil {
397 cwd = abs
398 }
399 var dirs []string
400 for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
401 dirs = append(dirs, dir)
402 parent := filepath.Dir(dir)
403 if parent == dir {
404 break
405 }
406 }
407 for i := len(dirs) - 1; i >= 0; i-- {
408 addFirstFromDir(dirs[i])
409 }
410 }
411
412 if len(files) == 0 {
413 return ""
414 }
415 var sb strings.Builder
416 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")
417 for _, f := range files {
418 fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
419 }
420 return strings.TrimSpace(sb.String())
421}
422
423// descMapFromSummaries indexes the human-readable descriptions for
424// the renderToolsSection rebuild path.
425func descMapFromSummaries(summaries []ToolSummary) map[string]string {
426 out := make(map[string]string, len(summaries))
427 for _, s := range summaries {
428 out[s.Name] = s.Description
429 }
430 return out
431}
432
433// NewClient returns a provider.Client for r, choosing the auth mode
434// based on r.AuthMethod. Panics if no credential is present; callers
435// must check HasCredential() first.
436func (r Resolved) NewClient() provider.Client {
437 if !r.HasCredential() {
438 panic("NewClient called without credential; check HasCredential first")
439 }
440 switch r.Provider {
441 case "ollama":
442 return provider.NewOpenAI(r.Credential, r.BaseURL)
443 case "kimi":
444 inner := provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
445 if r.AuthMethod == "oauth" {
446 return r.wrapWithRefresh(inner)
447 }
448 return inner
449 case "openai":
450 if r.AuthMethod == "oauth" {
451 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
452 return r.wrapWithRefresh(inner)
453 }
454 return provider.NewOpenAI(r.Credential, r.BaseURL)
455 case "google":
456 // API-key only path. Gemini Generative Language API.
457 return provider.NewGemini(r.Credential, r.BaseURL)
458 default:
459 if r.AuthMethod == "oauth" {
460 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
461 return r.wrapWithRefresh(inner)
462 }
463 return provider.NewAnthropic(r.Credential, r.BaseURL)
464 }
465}
466
467// wrapWithRefresh wraps an OAuth client so the access token is
468// refreshed automatically before each API call. Without this, long
469// sessions (hours) silently fail when the 1-hour token expires.
470func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
471 provName := r.Provider
472 baseURL := r.BaseURL
473 accountID := r.AccountID
474
475 refreshFn := func(ctx context.Context) (string, error) {
476 tok, err := refreshIfExpired(provName, loadOAuthToken(provName))
477 if err != nil {
478 return "", err
479 }
480 return tok.AccessToken, nil
481 }
482
483 factory := func(token string) provider.Client {
484 switch provName {
485 case "openai":
486 return provider.NewOpenAICodex(token, accountID, baseURL)
487 case "kimi":
488 return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders())
489 default:
490 return provider.NewAnthropicOAuth(token, baseURL)
491 }
492 }
493
494 return provider.NewRefreshingClient(inner, refreshFn, factory)
495}
496
497// UseSandbox replaces the sandbox pointer that every tool in r's
498// registry references. Used to keep the /jail state stable across
499// agent rebuilds (e.g. /login, /model switching providers).
500func (r *Resolved) UseSandbox(s *tools.Sandbox) {
501 if s == nil || r == nil {
502 return
503 }
504 r.Sandbox = s
505 for name, t := range r.ToolRegistry {
506 switch v := t.(type) {
507 case *tools.ReadTool:
508 v.Sandbox = s
509 case *tools.WriteTool:
510 v.Sandbox = s
511 case *tools.EditTool:
512 v.Sandbox = s
513 case *tools.BashTool:
514 v.Sandbox = s
515 }
516 _ = name
517 }
518}
519
520// NewAgent constructs a core.Agent from r. Requires a credential.
521func (r Resolved) NewAgent() *core.Agent {
522 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
523 a.MaxSteps = r.MaxSteps
524 a.Reasoning = r.Reasoning
525 return a
526}
527
528func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
529 if args.NoTools {
530 return core.Registry{}
531 }
532 all := map[string]core.Tool{
533 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
534 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
535 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
536 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
537 }
538 reg := core.Registry{}
539 if len(args.Tools) == 0 {
540 for _, t := range all {
541 reg[t.Name()] = t
542 }
543 return reg
544 }
545 for _, name := range args.Tools {
546 if t, ok := all[name]; ok {
547 reg[name] = t
548 }
549 }
550 return reg
551}
552
553func toolSummaries(reg core.Registry, args Args) []ToolSummary {
554 order := []string{"read", "write", "edit", "bash"}
555 var out []ToolSummary
556 for _, name := range order {
557 if t, ok := reg[name]; ok {
558 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
559 }
560 }
561 return out
562}
563
564func firstNonEmpty(vals ...string) string {
565 for _, v := range vals {
566 if v != "" {
567 return v
568 }
569 }
570 return ""
571}
572
573func kimiCodeHeaders() map[string]string {
574 host, _ := os.Hostname()
575 if host == "" {
576 host = "unknown"
577 }
578 deviceID := ""
579 if home, err := os.UserHomeDir(); err == nil {
580 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
581 deviceID = strings.TrimSpace(string(b))
582 }
583 }
584 if deviceID == "" {
585 deviceID = "zot"
586 }
587 return map[string]string{
588 "User-Agent": "KimiCLI/1.41.0",
589 "X-Msh-Platform": "kimi_cli",
590 "X-Msh-Version": "1.41.0",
591 "X-Msh-Device-Name": host,
592 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
593 "X-Msh-Os-Version": runtime.GOOS,
594 "X-Msh-Device-Id": deviceID,
595 }
596}
597
598func envVarName(provider string) string {
599 switch provider {
600 case "openai":
601 return "OPENAI"
602 case "kimi":
603 return "KIMI"
604 case "google":
605 return "GEMINI"
606 case "ollama":
607 return "OLLAMA"
608 default:
609 return "ANTHROPIC"
610 }
611}