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