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 != "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"} {
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 "ollama":
193 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
194 default:
195 model = provider.DefaultModel.ID
196 }
197 }
198 // If the resolved model belongs to a different provider (e.g. config
199 // says gpt-5 but we fell back to anthropic), pick that provider's default.
200 if provName != "ollama" {
201 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
202 switch provName {
203 case "openai":
204 model = "gpt-5"
205 case "kimi":
206 model = "kimi-for-coding"
207 default:
208 model = provider.DefaultModel.ID
209 }
210 }
211 }
212 resolvedModel, err := provider.FindModel(provName, model)
213 if err != nil && provName != "ollama" {
214 return Resolved{}, err
215 }
216 if err != nil && provName == "ollama" {
217 resolvedModel = provider.Model{
218 Provider: "ollama",
219 ID: model,
220 DisplayName: model,
221 ContextWindow: 32768,
222 MaxOutput: 8192,
223 BaseURL: args.BaseURL,
224 Source: "ollama",
225 }
226 }
227
228 // If the model defines a base URL (e.g. local ollama) and the
229 // user didn't pass --base-url, use the model's URL.
230 if args.BaseURL == "" && resolvedModel.BaseURL != "" {
231 args.BaseURL = resolvedModel.BaseURL
232 }
233
234 // If the model has a base URL, credentials are optional (local
235 // models like ollama don't need real API keys).
236 if resolvedModel.BaseURL != "" && credErr != nil {
237 cred = "ollama"
238 credErr = nil
239 requireCred = false
240 }
241
242 if credErr != nil && requireCred {
243 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
244 credErr, envVarName(provName))
245 }
246
247 sandbox := tools.NewSandbox(args.CWD)
248 reg := buildToolRegistry(args, args.CWD, sandbox)
249
250 // Skill discovery: scan project + global locations + built-in
251 // skills shipped with the binary. If any are found, register
252 // the on-demand `skill` loader tool plus a system-prompt
253 // manifest so the model knows what's available.
254 //
255 // --no-skill bypasses the entire mechanism: no manifest in the
256 // system prompt, no `skill` tool in the registry. Useful for a
257 // clean-room run with zero extra context biasing the model.
258 var (
259 discovered []*skills.Skill
260 skillTool *skills.Tool
261 skillAddendum string
262 )
263 if !args.NoSkill {
264 homeDir, _ := os.UserHomeDir()
265 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
266 if len(discovered) > 0 {
267 skillTool = skills.NewTool(discovered)
268 reg[skillTool.Name()] = skillTool
269 skillAddendum = skills.SystemPromptAddendum(discovered)
270 }
271 }
272 _ = skillTool
273
274 summaries := toolSummaries(reg, args)
275
276 append_ := append([]string(nil), args.AppendSystemPrompt...)
277 if skillAddendum != "" {
278 append_ = append(append_, skillAddendum)
279 }
280
281 // Custom system prompt resolution order:
282 // 1. --system-prompt flag (highest priority; ad-hoc per run)
283 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
284 // 3. built-in default (defaultIdentity + defaultGuidelines)
285 custom := args.SystemPrompt
286 if custom == "" {
287 custom = readUserSystemPrompt(ZotHome())
288 }
289
290 sys := BuildSystemPrompt(SystemPromptOpts{
291 CWD: args.CWD,
292 Tools: summaries,
293 Custom: custom,
294 Append: append_,
295 })
296
297 reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
298
299 max := args.MaxSteps // 0 = unlimited
300
301 return Resolved{
302 Provider: provName,
303 Model: model,
304 Credential: cred,
305 AuthMethod: method,
306 AccountID: accountID,
307 BaseURL: args.BaseURL,
308 CWD: args.CWD,
309 Reasoning: reasoning,
310 ToolRegistry: reg,
311 ToolSummary: summaries,
312 SystemPrompt: sys,
313 MaxSteps: max,
314 Sandbox: sandbox,
315 SkillTool: skillTool,
316 systemAppend: append_,
317 systemCustom: custom,
318 toolDescriptions: descMapFromSummaries(summaries),
319 }, nil
320}
321
322// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
323// trimmed contents, or "" when the file is missing / unreadable /
324// empty. Errors are intentionally swallowed: the file is optional,
325// and any failure to read it should fall back to the built-in
326// default system prompt rather than crash the run.
327func readUserSystemPrompt(zotHome string) string {
328 if zotHome == "" {
329 return ""
330 }
331 path := filepath.Join(zotHome, "SYSTEM.md")
332 raw, err := os.ReadFile(path)
333 if err != nil {
334 return ""
335 }
336 return strings.TrimSpace(string(raw))
337}
338
339// descMapFromSummaries indexes the human-readable descriptions for
340// the renderToolsSection rebuild path.
341func descMapFromSummaries(summaries []ToolSummary) map[string]string {
342 out := make(map[string]string, len(summaries))
343 for _, s := range summaries {
344 out[s.Name] = s.Description
345 }
346 return out
347}
348
349// NewClient returns a provider.Client for r, choosing the auth mode
350// based on r.AuthMethod. Panics if no credential is present; callers
351// must check HasCredential() first.
352func (r Resolved) NewClient() provider.Client {
353 if !r.HasCredential() {
354 panic("NewClient called without credential; check HasCredential first")
355 }
356 switch r.Provider {
357 case "ollama":
358 return provider.NewOpenAI(r.Credential, r.BaseURL)
359 case "kimi":
360 inner := provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
361 if r.AuthMethod == "oauth" {
362 return r.wrapWithRefresh(inner)
363 }
364 return inner
365 case "openai":
366 if r.AuthMethod == "oauth" {
367 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
368 return r.wrapWithRefresh(inner)
369 }
370 return provider.NewOpenAI(r.Credential, r.BaseURL)
371 default:
372 if r.AuthMethod == "oauth" {
373 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
374 return r.wrapWithRefresh(inner)
375 }
376 return provider.NewAnthropic(r.Credential, r.BaseURL)
377 }
378}
379
380// wrapWithRefresh wraps an OAuth client so the access token is
381// refreshed automatically before each API call. Without this, long
382// sessions (hours) silently fail when the 1-hour token expires.
383func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
384 provName := r.Provider
385 baseURL := r.BaseURL
386 accountID := r.AccountID
387
388 refreshFn := func(ctx context.Context) (string, error) {
389 tok, err := refreshIfExpired(provName, loadOAuthToken(provName))
390 if err != nil {
391 return "", err
392 }
393 return tok.AccessToken, nil
394 }
395
396 factory := func(token string) provider.Client {
397 switch provName {
398 case "openai":
399 return provider.NewOpenAICodex(token, accountID, baseURL)
400 case "kimi":
401 return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders())
402 default:
403 return provider.NewAnthropicOAuth(token, baseURL)
404 }
405 }
406
407 return provider.NewRefreshingClient(inner, refreshFn, factory)
408}
409
410// UseSandbox replaces the sandbox pointer that every tool in r's
411// registry references. Used to keep the /jail state stable across
412// agent rebuilds (e.g. /login, /model switching providers).
413func (r *Resolved) UseSandbox(s *tools.Sandbox) {
414 if s == nil || r == nil {
415 return
416 }
417 r.Sandbox = s
418 for name, t := range r.ToolRegistry {
419 switch v := t.(type) {
420 case *tools.ReadTool:
421 v.Sandbox = s
422 case *tools.WriteTool:
423 v.Sandbox = s
424 case *tools.EditTool:
425 v.Sandbox = s
426 case *tools.BashTool:
427 v.Sandbox = s
428 }
429 _ = name
430 }
431}
432
433// NewAgent constructs a core.Agent from r. Requires a credential.
434func (r Resolved) NewAgent() *core.Agent {
435 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
436 a.MaxSteps = r.MaxSteps
437 a.Reasoning = r.Reasoning
438 return a
439}
440
441func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
442 if args.NoTools {
443 return core.Registry{}
444 }
445 all := map[string]core.Tool{
446 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
447 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
448 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
449 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
450 }
451 reg := core.Registry{}
452 if len(args.Tools) == 0 {
453 for _, t := range all {
454 reg[t.Name()] = t
455 }
456 return reg
457 }
458 for _, name := range args.Tools {
459 if t, ok := all[name]; ok {
460 reg[name] = t
461 }
462 }
463 return reg
464}
465
466func toolSummaries(reg core.Registry, args Args) []ToolSummary {
467 order := []string{"read", "write", "edit", "bash"}
468 var out []ToolSummary
469 for _, name := range order {
470 if t, ok := reg[name]; ok {
471 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
472 }
473 }
474 return out
475}
476
477func firstNonEmpty(vals ...string) string {
478 for _, v := range vals {
479 if v != "" {
480 return v
481 }
482 }
483 return ""
484}
485
486func kimiCodeHeaders() map[string]string {
487 host, _ := os.Hostname()
488 if host == "" {
489 host = "unknown"
490 }
491 deviceID := ""
492 if home, err := os.UserHomeDir(); err == nil {
493 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
494 deviceID = strings.TrimSpace(string(b))
495 }
496 }
497 if deviceID == "" {
498 deviceID = "zot"
499 }
500 return map[string]string{
501 "User-Agent": "KimiCLI/1.41.0",
502 "X-Msh-Platform": "kimi_cli",
503 "X-Msh-Version": "1.41.0",
504 "X-Msh-Device-Name": host,
505 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
506 "X-Msh-Os-Version": runtime.GOOS,
507 "X-Msh-Device-Id": deviceID,
508 }
509}
510
511func envVarName(provider string) string {
512 switch provider {
513 case "openai":
514 return "OPENAI"
515 case "kimi":
516 return "KIMI"
517 case "ollama":
518 return "OLLAMA"
519 default:
520 return "ANTHROPIC"
521 }
522}