1package agent
2
3import (
4 "fmt"
5
6 "github.com/patriceckhart/zot/internal/agent/tools"
7 "github.com/patriceckhart/zot/internal/core"
8 "github.com/patriceckhart/zot/internal/provider"
9)
10
11// Resolved is the effective configuration after merging CLI, config, defaults.
12type Resolved struct {
13 Provider string
14 Model string
15 Credential string // api key or oauth access token
16 AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
17 AccountID string // ChatGPT account id (for openai oauth), "" otherwise
18 BaseURL string
19 CWD string
20 Reasoning string
21
22 ToolRegistry core.Registry
23 ToolSummary []ToolSummary
24 SystemPrompt string
25 MaxSteps int
26 Sandbox *tools.Sandbox
27}
28
29// HasCredential reports whether a credential was resolved.
30func (r Resolved) HasCredential() bool { return r.Credential != "" }
31
32// Resolve merges args, config, and env into a Resolved set.
33//
34// Unlike the earlier version, Resolve NEVER returns an error for
35// missing credentials: the TUI can start without them and launch a
36// login flow. requireCred controls whether missing credentials are a
37// hard error (used by print/json modes).
38func Resolve(args Args, requireCred bool) (Resolved, error) {
39 cfg, _ := LoadConfig()
40
41 // User-requested provider (explicit > config > default).
42 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
43 if provName != "anthropic" && provName != "openai" {
44 return Resolved{}, fmt.Errorf("provider must be anthropic or openai (got %q)", provName)
45 }
46
47 // Try the requested provider first.
48 cred, method, accountID, credErr := ResolveCredentialFull(provName, args.APIKey)
49
50 // If the user did NOT explicitly pick a provider and the default one
51 // has no credentials, auto-fall-back to whichever provider is actually
52 // logged in. That way running plain `zot` after `/login` (any provider)
53 // never shows a "not logged in" banner.
54 userPickedProvider := args.Provider != ""
55 if credErr != nil && !userPickedProvider {
56 other := "openai"
57 if provName == "openai" {
58 other = "anthropic"
59 }
60 if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
61 provName = other
62 cred, method, accountID, credErr = c, m, a, err
63 }
64 }
65
66 model := firstNonEmpty(args.Model, cfg.Model)
67 if model == "" {
68 if provName == "openai" {
69 model = "gpt-5"
70 } else {
71 model = provider.DefaultModel.ID
72 }
73 }
74 // If the resolved model belongs to a different provider (e.g. config
75 // says gpt-5 but we fell back to anthropic), pick that provider's default.
76 if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
77 if provName == "openai" {
78 model = "gpt-5"
79 } else {
80 model = provider.DefaultModel.ID
81 }
82 }
83 if _, err := provider.FindModel(provName, model); err != nil {
84 return Resolved{}, err
85 }
86
87 if credErr != nil && requireCred {
88 return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
89 credErr, envVarName(provName))
90 }
91
92 sandbox := tools.NewSandbox(args.CWD)
93 reg := buildToolRegistry(args, args.CWD, sandbox)
94 summaries := toolSummaries(reg, args)
95
96 sys := BuildSystemPrompt(SystemPromptOpts{
97 CWD: args.CWD,
98 Tools: summaries,
99 Custom: args.SystemPrompt,
100 Append: args.AppendSystemPrompt,
101 })
102
103 reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
104
105 max := args.MaxSteps
106 if max <= 0 {
107 max = 50
108 }
109
110 return Resolved{
111 Provider: provName,
112 Model: model,
113 Credential: cred,
114 AuthMethod: method,
115 AccountID: accountID,
116 BaseURL: args.BaseURL,
117 CWD: args.CWD,
118 Reasoning: reasoning,
119 ToolRegistry: reg,
120 ToolSummary: summaries,
121 SystemPrompt: sys,
122 MaxSteps: max,
123 Sandbox: sandbox,
124 }, nil
125}
126
127// NewClient returns a provider.Client for r, choosing the auth mode
128// based on r.AuthMethod. Panics if no credential is present; callers
129// must check HasCredential() first.
130func (r Resolved) NewClient() provider.Client {
131 if !r.HasCredential() {
132 panic("NewClient called without credential; check HasCredential first")
133 }
134 if r.Provider == "openai" {
135 if r.AuthMethod == "oauth" {
136 return provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
137 }
138 return provider.NewOpenAI(r.Credential, r.BaseURL)
139 }
140 if r.AuthMethod == "oauth" {
141 return provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
142 }
143 return provider.NewAnthropic(r.Credential, r.BaseURL)
144}
145
146// UseSandbox replaces the sandbox pointer that every tool in r's
147// registry references. Used to keep the /lock state stable across
148// agent rebuilds (e.g. /login, /model switching providers).
149func (r *Resolved) UseSandbox(s *tools.Sandbox) {
150 if s == nil || r == nil {
151 return
152 }
153 r.Sandbox = s
154 for name, t := range r.ToolRegistry {
155 switch v := t.(type) {
156 case *tools.ReadTool:
157 v.Sandbox = s
158 case *tools.WriteTool:
159 v.Sandbox = s
160 case *tools.EditTool:
161 v.Sandbox = s
162 case *tools.BashTool:
163 v.Sandbox = s
164 }
165 _ = name
166 }
167}
168
169// NewAgent constructs a core.Agent from r. Requires a credential.
170func (r Resolved) NewAgent() *core.Agent {
171 a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
172 a.MaxSteps = r.MaxSteps
173 a.Reasoning = r.Reasoning
174 return a
175}
176
177func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
178 if args.NoTools {
179 return core.Registry{}
180 }
181 all := map[string]core.Tool{
182 "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
183 "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
184 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
185 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
186 }
187 reg := core.Registry{}
188 if len(args.Tools) == 0 {
189 for _, t := range all {
190 reg[t.Name()] = t
191 }
192 return reg
193 }
194 for _, name := range args.Tools {
195 if t, ok := all[name]; ok {
196 reg[name] = t
197 }
198 }
199 return reg
200}
201
202func toolSummaries(reg core.Registry, args Args) []ToolSummary {
203 order := []string{"read", "write", "edit", "bash"}
204 var out []ToolSummary
205 for _, name := range order {
206 if t, ok := reg[name]; ok {
207 out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
208 }
209 }
210 return out
211}
212
213func firstNonEmpty(vals ...string) string {
214 for _, v := range vals {
215 if v != "" {
216 return v
217 }
218 }
219 return ""
220}
221
222func envVarName(provider string) string {
223 switch provider {
224 case "openai":
225 return "OPENAI"
226 default:
227 return "ANTHROPIC"
228 }
229}