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
289 if max <= 0 {
290 max = 50
291 }
292
293 return Resolved{
294 Provider: provName,
295 Model: model,
296 Credential: cred,
297 AuthMethod: method,
298 AccountID: accountID,
299 BaseURL: args.BaseURL,
300 CWD: args.CWD,
301 Reasoning: reasoning,
302 ToolRegistry: reg,
303 ToolSummary: summaries,
304 SystemPrompt: sys,
305 MaxSteps: max,
306 Sandbox: sandbox,
307 SkillTool: skillTool,
308 systemAppend: append_,
309 systemCustom: custom,
310 toolDescriptions: descMapFromSummaries(summaries),
311 }, nil
312}
313
314// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
315// trimmed contents, or "" when the file is missing / unreadable /
316// empty. Errors are intentionally swallowed: the file is optional,
317// and any failure to read it should fall back to the built-in
318// default system prompt rather than crash the run.
319func readUserSystemPrompt(zotHome string) string {
320 if zotHome == "" {
321 return ""
322 }
323 path := filepath.Join(zotHome, "SYSTEM.md")
324 raw, err := os.ReadFile(path)
325 if err != nil {
326 return ""
327 }
328 return strings.TrimSpace(string(raw))
329}
330
331// descMapFromSummaries indexes the human-readable descriptions for
332// the renderToolsSection rebuild path.
333func descMapFromSummaries(summaries []ToolSummary) map[string]string {
334 out := make(map[string]string, len(summaries))
335 for _, s := range summaries {
336 out[s.Name] = s.Description
337 }
338 return out
339}
340
341// NewClient returns a provider.Client for r, choosing the auth mode
342// based on r.AuthMethod. Panics if no credential is present; callers
343// must check HasCredential() first.
344func (r Resolved) NewClient() provider.Client {
345 if !r.HasCredential() {
346 panic("NewClient called without credential; check HasCredential first")
347 }
348 switch r.Provider {
349 case "ollama":
350 return provider.NewOpenAI(r.Credential, r.BaseURL)
351 case "openai":
352 if r.AuthMethod == "oauth" {
353 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
354 return r.wrapWithRefresh(inner)
355 }
356 return provider.NewOpenAI(r.Credential, r.BaseURL)
357 default:
358 if r.AuthMethod == "oauth" {
359 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
360 return r.wrapWithRefresh(inner)
361 }
362 return provider.NewAnthropic(r.Credential, r.BaseURL)
363 }
364}
365
366// wrapWithRefresh wraps an OAuth client so the access token is
367// refreshed automatically before each API call. Without this, long
368// sessions (hours) silently fail when the 1-hour token expires.
369func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
370 provName := r.Provider
371 baseURL := r.BaseURL
372 accountID := r.AccountID
373
374 refreshFn := func(ctx context.Context) (string, error) {
375 tok, err := refreshIfExpired(provName, loadOAuthToken(provName))
376 if err != nil {
377 return "", err
378 }
379 return tok.AccessToken, nil
380 }
381
382 factory := func(token string) provider.Client {
383 switch provName {
384 case "openai":
385 return provider.NewOpenAICodex(token, accountID, baseURL)
386 default:
387 return provider.NewAnthropicOAuth(token, baseURL)
388 }
389 }
390
391 return provider.NewRefreshingClient(inner, refreshFn, factory)
392}
393
394// UseSandbox replaces the sandbox pointer that every tool in r's
395// registry references. Used to keep the /jail state stable across
396// agent rebuilds (e.g. /login, /model switching providers).
397func (r *Resolved) UseSandbox(s *tools.Sandbox) {
398 if s == nil || r == nil {
399 return
400 }
401 r.Sandbox = s
402 for name, t := range r.ToolRegistry {
403 switch v := t.(type) {
404 case *tools.ReadTool:
405 v.Sandbox = s
406 case *tools.WriteTool:
407 v.Sandbox = s
408 case *tools.EditTool:
409 v.Sandbox = s
410 case *tools.BashTool:
411 v.Sandbox = s
412 }
413 _ = name
414 }
415}
416
417// NewAgent constructs a core.Agent from r. Requires a credential.
418func (r Resolved) NewAgent() *core.Agent {
419 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
420 a.MaxSteps = r.MaxSteps
421 a.Reasoning = r.Reasoning
422 return a
423}
424
425func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
426 if args.NoTools {
427 return core.Registry{}
428 }
429 all := map[string]core.Tool{
430 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
431 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
432 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
433 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
434 }
435 reg := core.Registry{}
436 if len(args.Tools) == 0 {
437 for _, t := range all {
438 reg[t.Name()] = t
439 }
440 return reg
441 }
442 for _, name := range args.Tools {
443 if t, ok := all[name]; ok {
444 reg[name] = t
445 }
446 }
447 return reg
448}
449
450func toolSummaries(reg core.Registry, args Args) []ToolSummary {
451 order := []string{"read", "write", "edit", "bash"}
452 var out []ToolSummary
453 for _, name := range order {
454 if t, ok := reg[name]; ok {
455 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
456 }
457 }
458 return out
459}
460
461func firstNonEmpty(vals ...string) string {
462 for _, v := range vals {
463 if v != "" {
464 return v
465 }
466 }
467 return ""
468}
469
470func envVarName(provider string) string {
471 switch provider {
472 case "openai":
473 return "OPENAI"
474 case "ollama":
475 return "OLLAMA"
476 default:
477 return "ANTHROPIC"
478 }
479}