1package agent
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9
10 "github.com/patriceckhart/zot/internal/agent/tools"
11 "github.com/patriceckhart/zot/internal/core"
12 "github.com/patriceckhart/zot/internal/provider"
13 "github.com/patriceckhart/zot/internal/skills"
14)
15
16// Resolved is the effective configuration after merging CLI, config, defaults.
17type Resolved struct {
18 Provider string
19 Model string
20 Credential string // api key or oauth access token
21 AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
22 AccountID string // ChatGPT account id (for openai oauth), "" otherwise
23 BaseURL string
24 CWD string
25 Reasoning string
26
27 ToolRegistry core.Registry
28 ToolSummary []ToolSummary
29 SystemPrompt string
30 MaxSteps int
31 Sandbox *tools.Sandbox
32
33 // SkillTool is the on-demand skill loader registered with the
34 // agent's tool registry, or nil if no SKILL.md files were
35 // discovered. Exposed so the tui can list / preview skills.
36 SkillTool *skills.Tool
37
38 // Bookkeeping for MergeExtensionTools. Captured at Resolve time
39 // so the system prompt can be rebuilt later without re-running
40 // resolve.
41 systemAppend []string
42 systemCustom string
43 toolDescriptions map[string]string
44}
45
46// HasCredential reports whether a credential was resolved.
47func (r Resolved) HasCredential() bool { return r.Credential != "" }
48
49// MergeExtensionTools folds every tool registered by an extension
50// into r's ToolRegistry and re-renders the system prompt's tool
51// summary so the model sees both built-in and extension tools.
52//
53// Idempotent: calling twice with the same manager state has no
54// effect on the second pass (existing names are preserved). Built-in
55// tools always win on conflict.
56func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
57 if mgr == nil {
58 return
59 }
60 infos := mgr.Tools()
61 if len(infos) == 0 {
62 return
63 }
64 changed := false
65 for _, info := range infos {
66 if _, exists := r.ToolRegistry[info.Name]; exists {
67 continue
68 }
69 r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
70 changed = true
71 }
72 if !changed {
73 return
74 }
75 // Re-render the system prompt with the merged tool list. Skill
76 // addendum is preserved by walking the existing append slice.
77 append_ := r.systemAppend
78 r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
79 CWD: r.CWD,
80 Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
81 Custom: r.systemCustom,
82 Append: append_,
83 })
84}
85
86// ExtensionToolSource is the slice of the extension manager that
87// MergeExtensionTools needs. Lives here as an interface so the
88// build package doesn't import internal/agent/extensions (which
89// imports core, which imports... avoid the cycle).
90type ExtensionToolSource interface {
91 Tools() []ExtensionToolInfo
92 NewExtensionTool(info ExtensionToolInfo) core.Tool
93}
94
95// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
96// ExtensionToolSource here without importing the extensions
97// package. The cli wires a tiny adapter to bridge them.
98type ExtensionToolInfo struct {
99 Extension string
100 Name string
101 Description string
102 Schema []byte
103}
104
105// toolSummariesFromRegistry rebuilds the system-prompt tool list
106// from a (possibly extended) registry, using cached descriptions for
107// the human-readable summary text.
108func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
109 out := make([]ToolSummary, 0, len(reg))
110 for name, t := range reg {
111 desc := t.Description()
112 if d, ok := cached[name]; ok && d != "" {
113 desc = d
114 }
115 out = append(out, ToolSummary{Name: name, Description: desc})
116 }
117 return out
118}
119
120// Resolve merges args, config, and env into a Resolved set.
121//
122// Unlike the earlier version, Resolve NEVER returns an error for
123// missing credentials: the TUI can start without them and launch a
124// login flow. requireCred controls whether missing credentials are a
125// hard error (used by print/json modes).
126func Resolve(args Args, requireCred bool) (Resolved, error) {
127 cfg, _ := LoadConfig()
128
129 // User-requested provider (explicit > config > default).
130 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
131 if provName != "anthropic" && provName != "openai" && provName != "ollama" {
132 // Unknown provider (maybe removed or renamed). Fall back to
133 // the first provider that has credentials, or anthropic.
134 provName = "anthropic"
135 if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
136 provName = "openai"
137 }
138 if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
139 provName = "anthropic"
140 }
141 // Reset the saved config so this doesn't keep happening.
142 cfg.Provider = provName
143 cfg.Model = ""
144 _ = SaveConfig(cfg)
145 }
146
147 var (
148 cred string
149 method string
150 accountID string
151 credErr error
152 )
153 if provName == "ollama" {
154 if args.BaseURL == "" {
155 args.BaseURL = "http://localhost:11434"
156 }
157 cred = firstNonEmpty(args.APIKey, "ollama")
158 method = "apikey"
159 } else {
160 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
161 }
162
163 // If the user did NOT explicitly pick a provider and the default one
164 // has no credentials, auto-fall-back to whichever provider is actually
165 // logged in. That way running plain `zot` after `/login` (any provider)
166 // never shows a "not logged in" banner.
167 userPickedProvider := args.Provider != ""
168 if credErr != nil && !userPickedProvider && provName != "ollama" {
169 other := "openai"
170 if provName == "openai" {
171 other = "anthropic"
172 }
173 if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
174 provName = other
175 cred, method, accountID, credErr = c, m, a, err
176 }
177 }
178
179 model := firstNonEmpty(args.Model, cfg.Model)
180 if model == "" {
181 switch provName {
182 case "openai":
183 model = "gpt-5"
184 case "ollama":
185 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
186 default:
187 model = provider.DefaultModel.ID
188 }
189 }
190 // If the resolved model belongs to a different provider (e.g. config
191 // says gpt-5 but we fell back to anthropic), pick that provider's default.
192 if provName != "ollama" {
193 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
194 if provName == "openai" {
195 model = "gpt-5"
196 } else {
197 model = provider.DefaultModel.ID
198 }
199 }
200 }
201 resolvedModel, err := provider.FindModel(provName, model)
202 if err != nil && provName != "ollama" {
203 return Resolved{}, err
204 }
205 if err != nil && provName == "ollama" {
206 resolvedModel = provider.Model{
207 Provider: "ollama",
208 ID: model,
209 DisplayName: model,
210 ContextWindow: 32768,
211 MaxOutput: 8192,
212 BaseURL: args.BaseURL,
213 Source: "ollama",
214 }
215 }
216
217 // If the model defines a base URL (e.g. local ollama) and the
218 // user didn't pass --base-url, use the model's URL.
219 if args.BaseURL == "" && resolvedModel.BaseURL != "" {
220 args.BaseURL = resolvedModel.BaseURL
221 }
222
223 // If the model has a base URL, credentials are optional (local
224 // models like ollama don't need real API keys).
225 if resolvedModel.BaseURL != "" && credErr != nil {
226 cred = "ollama"
227 credErr = nil
228 requireCred = false
229 }
230
231 if credErr != nil && requireCred {
232 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
233 credErr, envVarName(provName))
234 }
235
236 sandbox := tools.NewSandbox(args.CWD)
237 reg := buildToolRegistry(args, args.CWD, sandbox)
238
239 // Skill discovery: scan project + global locations + built-in
240 // skills shipped with the binary. If any are found, register
241 // the on-demand `skill` loader tool plus a system-prompt
242 // manifest so the model knows what's available.
243 //
244 // --no-skill bypasses the entire mechanism: no manifest in the
245 // system prompt, no `skill` tool in the registry. Useful for a
246 // clean-room run with zero extra context biasing the model.
247 var (
248 discovered []*skills.Skill
249 skillTool *skills.Tool
250 skillAddendum string
251 )
252 if !args.NoSkill {
253 homeDir, _ := os.UserHomeDir()
254 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
255 if len(discovered) > 0 {
256 skillTool = skills.NewTool(discovered)
257 reg[skillTool.Name()] = skillTool
258 skillAddendum = skills.SystemPromptAddendum(discovered)
259 }
260 }
261 _ = skillTool
262
263 summaries := toolSummaries(reg, args)
264
265 append_ := append([]string(nil), args.AppendSystemPrompt...)
266 if skillAddendum != "" {
267 append_ = append(append_, skillAddendum)
268 }
269
270 // Custom system prompt resolution order:
271 // 1. --system-prompt flag (highest priority; ad-hoc per run)
272 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
273 // 3. built-in default (defaultIdentity + defaultGuidelines)
274 custom := args.SystemPrompt
275 if custom == "" {
276 custom = readUserSystemPrompt(ZotHome())
277 }
278
279 sys := BuildSystemPrompt(SystemPromptOpts{
280 CWD: args.CWD,
281 Tools: summaries,
282 Custom: custom,
283 Append: append_,
284 })
285
286 reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
287
288 max := args.MaxSteps // 0 = unlimited
289
290 return Resolved{
291 Provider: provName,
292 Model: model,
293 Credential: cred,
294 AuthMethod: method,
295 AccountID: accountID,
296 BaseURL: args.BaseURL,
297 CWD: args.CWD,
298 Reasoning: reasoning,
299 ToolRegistry: reg,
300 ToolSummary: summaries,
301 SystemPrompt: sys,
302 MaxSteps: max,
303 Sandbox: sandbox,
304 SkillTool: skillTool,
305 systemAppend: append_,
306 systemCustom: custom,
307 toolDescriptions: descMapFromSummaries(summaries),
308 }, nil
309}
310
311// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
312// trimmed contents, or "" when the file is missing / unreadable /
313// empty. Errors are intentionally swallowed: the file is optional,
314// and any failure to read it should fall back to the built-in
315// default system prompt rather than crash the run.
316func readUserSystemPrompt(zotHome string) string {
317 if zotHome == "" {
318 return ""
319 }
320 path := filepath.Join(zotHome, "SYSTEM.md")
321 raw, err := os.ReadFile(path)
322 if err != nil {
323 return ""
324 }
325 return strings.TrimSpace(string(raw))
326}
327
328// descMapFromSummaries indexes the human-readable descriptions for
329// the renderToolsSection rebuild path.
330func descMapFromSummaries(summaries []ToolSummary) map[string]string {
331 out := make(map[string]string, len(summaries))
332 for _, s := range summaries {
333 out[s.Name] = s.Description
334 }
335 return out
336}
337
338// NewClient returns a provider.Client for r, choosing the auth mode
339// based on r.AuthMethod. Panics if no credential is present; callers
340// must check HasCredential() first.
341func (r Resolved) NewClient() provider.Client {
342 if !r.HasCredential() {
343 panic("NewClient called without credential; check HasCredential first")
344 }
345 switch r.Provider {
346 case "ollama":
347 return provider.NewOpenAI(r.Credential, r.BaseURL)
348 case "openai":
349 if r.AuthMethod == "oauth" {
350 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
351 return r.wrapWithRefresh(inner)
352 }
353 return provider.NewOpenAI(r.Credential, r.BaseURL)
354 default:
355 if r.AuthMethod == "oauth" {
356 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
357 return r.wrapWithRefresh(inner)
358 }
359 return provider.NewAnthropic(r.Credential, r.BaseURL)
360 }
361}
362
363// wrapWithRefresh wraps an OAuth client so the access token is
364// refreshed automatically before each API call. Without this, long
365// sessions (hours) silently fail when the 1-hour token expires.
366func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
367 provName := r.Provider
368 baseURL := r.BaseURL
369 accountID := r.AccountID
370
371 refreshFn := func(ctx context.Context) (string, error) {
372 tok, err := refreshIfExpired(provName, loadOAuthToken(provName))
373 if err != nil {
374 return "", err
375 }
376 return tok.AccessToken, nil
377 }
378
379 factory := func(token string) provider.Client {
380 switch provName {
381 case "openai":
382 return provider.NewOpenAICodex(token, accountID, baseURL)
383 default:
384 return provider.NewAnthropicOAuth(token, baseURL)
385 }
386 }
387
388 return provider.NewRefreshingClient(inner, refreshFn, factory)
389}
390
391// UseSandbox replaces the sandbox pointer that every tool in r's
392// registry references. Used to keep the /jail state stable across
393// agent rebuilds (e.g. /login, /model switching providers).
394func (r *Resolved) UseSandbox(s *tools.Sandbox) {
395 if s == nil || r == nil {
396 return
397 }
398 r.Sandbox = s
399 for name, t := range r.ToolRegistry {
400 switch v := t.(type) {
401 case *tools.ReadTool:
402 v.Sandbox = s
403 case *tools.WriteTool:
404 v.Sandbox = s
405 case *tools.EditTool:
406 v.Sandbox = s
407 case *tools.BashTool:
408 v.Sandbox = s
409 }
410 _ = name
411 }
412}
413
414// NewAgent constructs a core.Agent from r. Requires a credential.
415func (r Resolved) NewAgent() *core.Agent {
416 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
417 a.MaxSteps = r.MaxSteps
418 a.Reasoning = r.Reasoning
419 return a
420}
421
422func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
423 if args.NoTools {
424 return core.Registry{}
425 }
426 all := map[string]core.Tool{
427 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
428 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
429 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
430 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
431 }
432 reg := core.Registry{}
433 if len(args.Tools) == 0 {
434 for _, t := range all {
435 reg[t.Name()] = t
436 }
437 return reg
438 }
439 for _, name := range args.Tools {
440 if t, ok := all[name]; ok {
441 reg[name] = t
442 }
443 }
444 return reg
445}
446
447func toolSummaries(reg core.Registry, args Args) []ToolSummary {
448 order := []string{"read", "write", "edit", "bash"}
449 var out []ToolSummary
450 for _, name := range order {
451 if t, ok := reg[name]; ok {
452 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
453 }
454 }
455 return out
456}
457
458func firstNonEmpty(vals ...string) string {
459 for _, v := range vals {
460 if v != "" {
461 return v
462 }
463 }
464 return ""
465}
466
467func envVarName(provider string) string {
468 switch provider {
469 case "openai":
470 return "OPENAI"
471 case "ollama":
472 return "OLLAMA"
473 default:
474 return "ANTHROPIC"
475 }
476}