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 skillAddendum != "" {
282 append_ = append(append_, skillAddendum)
283 }
284
285 // Custom system prompt resolution order:
286 // 1. --system-prompt flag (highest priority; ad-hoc per run)
287 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
288 // 3. built-in default (defaultIdentity + defaultGuidelines)
289 custom := args.SystemPrompt
290 if custom == "" {
291 custom = readUserSystemPrompt(ZotHome())
292 }
293
294 sys := BuildSystemPrompt(SystemPromptOpts{
295 CWD: args.CWD,
296 Tools: summaries,
297 Custom: custom,
298 Append: append_,
299 })
300
301 reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
302
303 max := args.MaxSteps // 0 = unlimited
304
305 return Resolved{
306 Provider: provName,
307 Model: model,
308 Credential: cred,
309 AuthMethod: method,
310 AccountID: accountID,
311 BaseURL: args.BaseURL,
312 CWD: args.CWD,
313 Reasoning: reasoning,
314 ToolRegistry: reg,
315 ToolSummary: summaries,
316 SystemPrompt: sys,
317 MaxSteps: max,
318 Sandbox: sandbox,
319 SkillTool: skillTool,
320 systemAppend: append_,
321 systemCustom: custom,
322 toolDescriptions: descMapFromSummaries(summaries),
323 }, nil
324}
325
326// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
327// trimmed contents, or "" when the file is missing / unreadable /
328// empty. Errors are intentionally swallowed: the file is optional,
329// and any failure to read it should fall back to the built-in
330// default system prompt rather than crash the run.
331func readUserSystemPrompt(zotHome string) string {
332 if zotHome == "" {
333 return ""
334 }
335 path := filepath.Join(zotHome, "SYSTEM.md")
336 raw, err := os.ReadFile(path)
337 if err != nil {
338 return ""
339 }
340 return strings.TrimSpace(string(raw))
341}
342
343// descMapFromSummaries indexes the human-readable descriptions for
344// the renderToolsSection rebuild path.
345func descMapFromSummaries(summaries []ToolSummary) map[string]string {
346 out := make(map[string]string, len(summaries))
347 for _, s := range summaries {
348 out[s.Name] = s.Description
349 }
350 return out
351}
352
353// NewClient returns a provider.Client for r, choosing the auth mode
354// based on r.AuthMethod. Panics if no credential is present; callers
355// must check HasCredential() first.
356func (r Resolved) NewClient() provider.Client {
357 if !r.HasCredential() {
358 panic("NewClient called without credential; check HasCredential first")
359 }
360 switch r.Provider {
361 case "ollama":
362 return provider.NewOpenAI(r.Credential, r.BaseURL)
363 case "kimi":
364 inner := provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
365 if r.AuthMethod == "oauth" {
366 return r.wrapWithRefresh(inner)
367 }
368 return inner
369 case "openai":
370 if r.AuthMethod == "oauth" {
371 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
372 return r.wrapWithRefresh(inner)
373 }
374 return provider.NewOpenAI(r.Credential, r.BaseURL)
375 case "google":
376 // API-key only path. Gemini Generative Language API.
377 return provider.NewGemini(r.Credential, r.BaseURL)
378 default:
379 if r.AuthMethod == "oauth" {
380 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
381 return r.wrapWithRefresh(inner)
382 }
383 return provider.NewAnthropic(r.Credential, r.BaseURL)
384 }
385}
386
387// wrapWithRefresh wraps an OAuth client so the access token is
388// refreshed automatically before each API call. Without this, long
389// sessions (hours) silently fail when the 1-hour token expires.
390func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
391 provName := r.Provider
392 baseURL := r.BaseURL
393 accountID := r.AccountID
394
395 refreshFn := func(ctx context.Context) (string, error) {
396 tok, err := refreshIfExpired(provName, loadOAuthToken(provName))
397 if err != nil {
398 return "", err
399 }
400 return tok.AccessToken, nil
401 }
402
403 factory := func(token string) provider.Client {
404 switch provName {
405 case "openai":
406 return provider.NewOpenAICodex(token, accountID, baseURL)
407 case "kimi":
408 return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders())
409 default:
410 return provider.NewAnthropicOAuth(token, baseURL)
411 }
412 }
413
414 return provider.NewRefreshingClient(inner, refreshFn, factory)
415}
416
417// UseSandbox replaces the sandbox pointer that every tool in r's
418// registry references. Used to keep the /jail state stable across
419// agent rebuilds (e.g. /login, /model switching providers).
420func (r *Resolved) UseSandbox(s *tools.Sandbox) {
421 if s == nil || r == nil {
422 return
423 }
424 r.Sandbox = s
425 for name, t := range r.ToolRegistry {
426 switch v := t.(type) {
427 case *tools.ReadTool:
428 v.Sandbox = s
429 case *tools.WriteTool:
430 v.Sandbox = s
431 case *tools.EditTool:
432 v.Sandbox = s
433 case *tools.BashTool:
434 v.Sandbox = s
435 }
436 _ = name
437 }
438}
439
440// NewAgent constructs a core.Agent from r. Requires a credential.
441func (r Resolved) NewAgent() *core.Agent {
442 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
443 a.MaxSteps = r.MaxSteps
444 a.Reasoning = r.Reasoning
445 return a
446}
447
448func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
449 if args.NoTools {
450 return core.Registry{}
451 }
452 all := map[string]core.Tool{
453 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
454 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
455 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
456 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
457 }
458 reg := core.Registry{}
459 if len(args.Tools) == 0 {
460 for _, t := range all {
461 reg[t.Name()] = t
462 }
463 return reg
464 }
465 for _, name := range args.Tools {
466 if t, ok := all[name]; ok {
467 reg[name] = t
468 }
469 }
470 return reg
471}
472
473func toolSummaries(reg core.Registry, args Args) []ToolSummary {
474 order := []string{"read", "write", "edit", "bash"}
475 var out []ToolSummary
476 for _, name := range order {
477 if t, ok := reg[name]; ok {
478 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
479 }
480 }
481 return out
482}
483
484func firstNonEmpty(vals ...string) string {
485 for _, v := range vals {
486 if v != "" {
487 return v
488 }
489 }
490 return ""
491}
492
493func kimiCodeHeaders() map[string]string {
494 host, _ := os.Hostname()
495 if host == "" {
496 host = "unknown"
497 }
498 deviceID := ""
499 if home, err := os.UserHomeDir(); err == nil {
500 if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
501 deviceID = strings.TrimSpace(string(b))
502 }
503 }
504 if deviceID == "" {
505 deviceID = "zot"
506 }
507 return map[string]string{
508 "User-Agent": "KimiCLI/1.41.0",
509 "X-Msh-Platform": "kimi_cli",
510 "X-Msh-Version": "1.41.0",
511 "X-Msh-Device-Name": host,
512 "X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
513 "X-Msh-Os-Version": runtime.GOOS,
514 "X-Msh-Device-Id": deviceID,
515 }
516}
517
518func envVarName(provider string) string {
519 switch provider {
520 case "openai":
521 return "OPENAI"
522 case "kimi":
523 return "KIMI"
524 case "google":
525 return "GEMINI"
526 case "ollama":
527 return "OLLAMA"
528 default:
529 return "ANTHROPIC"
530 }
531}