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