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