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 return Resolved{}, fmt.Errorf("provider must be anthropic, openai, or ollama (got %q)", provName)
133 }
134
135 var (
136 cred string
137 method string
138 accountID string
139 credErr error
140 )
141 if provName == "ollama" {
142 if args.BaseURL == "" {
143 args.BaseURL = "http://localhost:11434"
144 }
145 cred = firstNonEmpty(args.APIKey, "ollama")
146 method = "apikey"
147 } else {
148 cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
149 }
150
151 // If the user did NOT explicitly pick a provider and the default one
152 // has no credentials, auto-fall-back to whichever provider is actually
153 // logged in. That way running plain `zot` after `/login` (any provider)
154 // never shows a "not logged in" banner.
155 userPickedProvider := args.Provider != ""
156 if credErr != nil && !userPickedProvider && provName != "ollama" {
157 other := "openai"
158 if provName == "openai" {
159 other = "anthropic"
160 }
161 if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
162 provName = other
163 cred, method, accountID, credErr = c, m, a, err
164 }
165 }
166
167 model := firstNonEmpty(args.Model, cfg.Model)
168 if model == "" {
169 switch provName {
170 case "openai":
171 model = "gpt-5"
172 case "ollama":
173 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
174 default:
175 model = provider.DefaultModel.ID
176 }
177 }
178 // If the resolved model belongs to a different provider (e.g. config
179 // says gpt-5 but we fell back to anthropic), pick that provider's default.
180 if provName != "ollama" {
181 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
182 if provName == "openai" {
183 model = "gpt-5"
184 } else {
185 model = provider.DefaultModel.ID
186 }
187 }
188 }
189 resolvedModel, err := provider.FindModel(provName, model)
190 if err != nil && provName != "ollama" {
191 return Resolved{}, err
192 }
193 if err != nil && provName == "ollama" {
194 resolvedModel = provider.Model{
195 Provider: "ollama",
196 ID: model,
197 DisplayName: model,
198 ContextWindow: 32768,
199 MaxOutput: 8192,
200 BaseURL: args.BaseURL,
201 Source: "ollama",
202 }
203 }
204
205 // If the model defines a base URL (e.g. local ollama) and the
206 // user didn't pass --base-url, use the model's URL.
207 if args.BaseURL == "" && resolvedModel.BaseURL != "" {
208 args.BaseURL = resolvedModel.BaseURL
209 }
210
211 // If the model has a base URL, credentials are optional (local
212 // models like ollama don't need real API keys).
213 if resolvedModel.BaseURL != "" && credErr != nil {
214 cred = "ollama"
215 credErr = nil
216 requireCred = false
217 }
218
219 if credErr != nil && requireCred {
220 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
221 credErr, envVarName(provName))
222 }
223
224 sandbox := tools.NewSandbox(args.CWD)
225 reg := buildToolRegistry(args, args.CWD, sandbox)
226
227 // Skill discovery: scan project + global locations + built-in
228 // skills shipped with the binary. If any are found, register
229 // the on-demand `skill` loader tool plus a system-prompt
230 // manifest so the model knows what's available.
231 //
232 // --no-skill bypasses the entire mechanism: no manifest in the
233 // system prompt, no `skill` tool in the registry. Useful for a
234 // clean-room run with zero extra context biasing the model.
235 var (
236 discovered []*skills.Skill
237 skillTool *skills.Tool
238 skillAddendum string
239 )
240 if !args.NoSkill {
241 homeDir, _ := os.UserHomeDir()
242 discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
243 if len(discovered) > 0 {
244 skillTool = skills.NewTool(discovered)
245 reg[skillTool.Name()] = skillTool
246 skillAddendum = skills.SystemPromptAddendum(discovered)
247 }
248 }
249 _ = skillTool
250
251 summaries := toolSummaries(reg, args)
252
253 append_ := append([]string(nil), args.AppendSystemPrompt...)
254 if skillAddendum != "" {
255 append_ = append(append_, skillAddendum)
256 }
257
258 // Custom system prompt resolution order:
259 // 1. --system-prompt flag (highest priority; ad-hoc per run)
260 // 2. $ZOT_HOME/SYSTEM.md (persistent user override)
261 // 3. built-in default (defaultIdentity + defaultGuidelines)
262 custom := args.SystemPrompt
263 if custom == "" {
264 custom = readUserSystemPrompt(ZotHome())
265 }
266
267 sys := BuildSystemPrompt(SystemPromptOpts{
268 CWD: args.CWD,
269 Tools: summaries,
270 Custom: custom,
271 Append: append_,
272 })
273
274 reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
275
276 max := args.MaxSteps
277 if max <= 0 {
278 max = 50
279 }
280
281 return Resolved{
282 Provider: provName,
283 Model: model,
284 Credential: cred,
285 AuthMethod: method,
286 AccountID: accountID,
287 BaseURL: args.BaseURL,
288 CWD: args.CWD,
289 Reasoning: reasoning,
290 ToolRegistry: reg,
291 ToolSummary: summaries,
292 SystemPrompt: sys,
293 MaxSteps: max,
294 Sandbox: sandbox,
295 SkillTool: skillTool,
296 systemAppend: append_,
297 systemCustom: custom,
298 toolDescriptions: descMapFromSummaries(summaries),
299 }, nil
300}
301
302// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
303// trimmed contents, or "" when the file is missing / unreadable /
304// empty. Errors are intentionally swallowed: the file is optional,
305// and any failure to read it should fall back to the built-in
306// default system prompt rather than crash the run.
307func readUserSystemPrompt(zotHome string) string {
308 if zotHome == "" {
309 return ""
310 }
311 path := filepath.Join(zotHome, "SYSTEM.md")
312 raw, err := os.ReadFile(path)
313 if err != nil {
314 return ""
315 }
316 return strings.TrimSpace(string(raw))
317}
318
319// descMapFromSummaries indexes the human-readable descriptions for
320// the renderToolsSection rebuild path.
321func descMapFromSummaries(summaries []ToolSummary) map[string]string {
322 out := make(map[string]string, len(summaries))
323 for _, s := range summaries {
324 out[s.Name] = s.Description
325 }
326 return out
327}
328
329// NewClient returns a provider.Client for r, choosing the auth mode
330// based on r.AuthMethod. Panics if no credential is present; callers
331// must check HasCredential() first.
332func (r Resolved) NewClient() provider.Client {
333 if !r.HasCredential() {
334 panic("NewClient called without credential; check HasCredential first")
335 }
336 switch r.Provider {
337 case "ollama":
338 return provider.NewOpenAI(r.Credential, r.BaseURL)
339 case "openai":
340 if r.AuthMethod == "oauth" {
341 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
342 return r.wrapWithRefresh(inner)
343 }
344 return provider.NewOpenAI(r.Credential, r.BaseURL)
345 default:
346 if r.AuthMethod == "oauth" {
347 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
348 return r.wrapWithRefresh(inner)
349 }
350 return provider.NewAnthropic(r.Credential, r.BaseURL)
351 }
352}
353
354// wrapWithRefresh wraps an OAuth client so the access token is
355// refreshed automatically before each API call. Without this, long
356// sessions (hours) silently fail when the 1-hour token expires.
357func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
358 provName := r.Provider
359 baseURL := r.BaseURL
360 accountID := r.AccountID
361
362 refreshFn := func(ctx context.Context) (string, error) {
363 tok, err := refreshIfExpired(provName, loadOAuthToken(provName))
364 if err != nil {
365 return "", err
366 }
367 return tok.AccessToken, nil
368 }
369
370 factory := func(token string) provider.Client {
371 switch provName {
372 case "openai":
373 return provider.NewOpenAICodex(token, accountID, baseURL)
374 default:
375 return provider.NewAnthropicOAuth(token, baseURL)
376 }
377 }
378
379 return provider.NewRefreshingClient(inner, refreshFn, factory)
380}
381
382// UseSandbox replaces the sandbox pointer that every tool in r's
383// registry references. Used to keep the /jail state stable across
384// agent rebuilds (e.g. /login, /model switching providers).
385func (r *Resolved) UseSandbox(s *tools.Sandbox) {
386 if s == nil || r == nil {
387 return
388 }
389 r.Sandbox = s
390 for name, t := range r.ToolRegistry {
391 switch v := t.(type) {
392 case *tools.ReadTool:
393 v.Sandbox = s
394 case *tools.WriteTool:
395 v.Sandbox = s
396 case *tools.EditTool:
397 v.Sandbox = s
398 case *tools.BashTool:
399 v.Sandbox = s
400 }
401 _ = name
402 }
403}
404
405// NewAgent constructs a core.Agent from r. Requires a credential.
406func (r Resolved) NewAgent() *core.Agent {
407 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
408 a.MaxSteps = r.MaxSteps
409 a.Reasoning = r.Reasoning
410 return a
411}
412
413func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
414 if args.NoTools {
415 return core.Registry{}
416 }
417 all := map[string]core.Tool{
418 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
419 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
420 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
421 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
422 }
423 reg := core.Registry{}
424 if len(args.Tools) == 0 {
425 for _, t := range all {
426 reg[t.Name()] = t
427 }
428 return reg
429 }
430 for _, name := range args.Tools {
431 if t, ok := all[name]; ok {
432 reg[name] = t
433 }
434 }
435 return reg
436}
437
438func toolSummaries(reg core.Registry, args Args) []ToolSummary {
439 order := []string{"read", "write", "edit", "bash"}
440 var out []ToolSummary
441 for _, name := range order {
442 if t, ok := reg[name]; ok {
443 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
444 }
445 }
446 return out
447}
448
449func firstNonEmpty(vals ...string) string {
450 for _, v := range vals {
451 if v != "" {
452 return v
453 }
454 }
455 return ""
456}
457
458func envVarName(provider string) string {
459 switch provider {
460 case "openai":
461 return "OPENAI"
462 case "ollama":
463 return "OLLAMA"
464 default:
465 return "ANTHROPIC"
466 }
467}