1// Package sdk is the public Go SDK for embedding the zot agent
2// runtime in third-party programs. It is the only stable, importable
3// surface the project exposes; everything under internal/ is subject
4// to change without notice.
5//
6// Lifecycle:
7//
8// rt, err := sdk.New(sdk.Config{Provider: "anthropic", Model: "claude-sonnet-4-5"})
9// if err != nil { ... }
10// defer rt.Close()
11//
12// events, err := rt.Prompt(ctx, "fix the failing test", nil)
13// if err != nil { ... }
14// for ev := range events {
15// fmt.Printf("%s: %+v\n", ev.Type(), ev)
16// }
17//
18// Concurrency model: one prompt at a time per Runtime. Spawn one
19// Runtime per project / cwd. The Cancel call interrupts the active
20// prompt; subsequent prompts work normally.
21//
22// For a non-Go consumer, run `zot rpc` and speak the same JSON
23// schema over stdin/stdout. See docs/rpc.md.
24package sdk
25
26import (
27 "context"
28 "errors"
29 "fmt"
30 "sync"
31
32 "github.com/patriceckhart/zot/packages/agent"
33 "github.com/patriceckhart/zot/packages/core"
34 "github.com/patriceckhart/zot/packages/provider"
35)
36
37// Config configures a Runtime. All fields are optional; sensible
38// defaults are read from $ZOT_HOME/config.json, env vars, and the
39// resolver chain (the same one the cli uses).
40type Config struct {
41 // Provider is "anthropic" or "openai". Empty = use the user's
42 // default from config.json or env.
43 Provider string
44
45 // Model is the model id. Empty = use the provider's default.
46 Model string
47
48 // CWD is the working directory the agent operates in. Tools are
49 // confined to this directory when Lock is true. Empty = current
50 // process cwd.
51 CWD string
52
53 // SystemPrompt overrides the built-in system prompt.
54 SystemPrompt string
55
56 // AppendSystemPrompt is appended to the built-in (or overridden)
57 // system prompt. Useful for project-specific instructions.
58 AppendSystemPrompt []string
59
60 // Reasoning sets the reasoning effort for models that support it
61 // ("minimum", "low", "medium", "high", "xhigh", "max"). Empty disables it.
62 Reasoning string
63
64 // Temperature sets the sampling temperature. Nil = provider default.
65 Temperature *float32
66
67 // MaxSteps caps the agent loop iterations per Prompt call.
68 // 0 uses the default (50).
69 MaxSteps int
70
71 // APIKey overrides the credential lookup chain.
72 APIKey string
73
74 // BaseURL overrides the provider base url (for tests / proxies).
75 BaseURL string
76
77 // Tools is the list of tools to enable. Nil/empty = all four
78 // (read, write, edit, bash). Pass an empty-but-non-nil slice
79 // (e.g. []string{}) plus NoTools=true to disable everything.
80 Tools []string
81
82 // NoTools disables every tool. Useful for chat-only embeddings.
83 NoTools bool
84
85 // Lock confines tools to CWD. Same effect as the /jail command.
86 Lock bool
87}
88
89// Runtime is one zot agent session. Safe for use from one goroutine
90// at a time per Runtime; create separate Runtimes for parallel work.
91type Runtime struct {
92 mu sync.Mutex
93 agent *core.Agent
94 provider string
95 model string
96 cwd string
97
98 // activeCancel is set while a Prompt is streaming.
99 activeCancel context.CancelFunc
100
101 // closed signals that Close has been called.
102 closed bool
103}
104
105// New constructs a Runtime from cfg. Returns an error if no
106// credential is available for the requested provider.
107func New(cfg Config) (*Runtime, error) {
108 args := agent.Args{
109 Mode: agent.ModeJSON, // headless
110 Provider: cfg.Provider,
111 Model: cfg.Model,
112 CWD: cfg.CWD,
113 APIKey: cfg.APIKey,
114 BaseURL: cfg.BaseURL,
115 SystemPrompt: cfg.SystemPrompt,
116 AppendSystemPrompt: cfg.AppendSystemPrompt,
117 Reasoning: cfg.Reasoning,
118 Temperature: cfg.Temperature,
119 MaxSteps: cfg.MaxSteps,
120 Tools: cfg.Tools,
121 NoTools: cfg.NoTools,
122 NoSess: true, // SDK callers manage persistence themselves
123 }
124 if args.MaxSteps == 0 {
125 args.MaxSteps = 50
126 }
127 r, err := agent.Resolve(args, true)
128 if err != nil {
129 return nil, err
130 }
131 if cfg.Lock && r.Sandbox != nil {
132 r.Sandbox.Lock()
133 }
134 ag := r.NewAgent()
135 return &Runtime{
136 agent: ag,
137 provider: r.Provider,
138 model: r.Model,
139 cwd: r.CWD,
140 }, nil
141}
142
143// Provider returns the active provider id.
144func (r *Runtime) Provider() string { r.mu.Lock(); defer r.mu.Unlock(); return r.provider }
145
146// Model returns the active model id.
147func (r *Runtime) Model() string { r.mu.Lock(); defer r.mu.Unlock(); return r.model }
148
149// CWD returns the working directory the agent operates in.
150func (r *Runtime) CWD() string { r.mu.Lock(); defer r.mu.Unlock(); return r.cwd }
151
152// Messages returns a copy of the current transcript.
153func (r *Runtime) Messages() []Message {
154 if r.agent == nil {
155 return nil
156 }
157 src := r.agent.Messages()
158 out := make([]Message, len(src))
159 for i, m := range src {
160 out[i] = Message{
161 Role: string(m.Role),
162 Content: convertContent(m.Content),
163 Time: m.Time.Format("2006-01-02T15:04:05Z"),
164 }
165 }
166 return out
167}
168
169// SetMessages replaces the transcript. Use to seed history from a
170// session file or to clear with nil.
171func (r *Runtime) SetMessages(msgs []Message) {
172 if r.agent == nil {
173 return
174 }
175 out := make([]provider.Message, 0, len(msgs))
176 for _, m := range msgs {
177 out = append(out, provider.Message{
178 Role: provider.Role(m.Role),
179 Content: rebuildContent(m.Content),
180 })
181 }
182 r.agent.SetMessages(out)
183}
184
185// Cost returns the cumulative usage and dollar cost for this Runtime.
186func (r *Runtime) Cost() Usage {
187 if r.agent == nil {
188 return Usage{}
189 }
190 u := r.agent.Cost()
191 return Usage{
192 Input: u.InputTokens,
193 Output: u.OutputTokens,
194 Reasoning: sdkReasoningTokens(u),
195 CacheRead: u.CacheReadTokens,
196 CacheWrite: u.CacheWriteTokens,
197 CostUSD: u.CostUSD,
198 }
199}
200
201// Prompt sends a user message and runs the agent loop. Returns a
202// channel that emits one Event per agent action, closed when the
203// turn finishes (cleanly or with error). Only one Prompt may be
204// active at a time per Runtime; concurrent calls return ErrBusy.
205func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-chan Event, error) {
206 r.mu.Lock()
207 if r.closed {
208 r.mu.Unlock()
209 return nil, ErrClosed
210 }
211 if r.activeCancel != nil {
212 r.mu.Unlock()
213 return nil, ErrBusy
214 }
215 if r.agent == nil {
216 r.mu.Unlock()
217 return nil, fmt.Errorf("sdk: no agent (login first via /login or set credentials)")
218 }
219 subCtx, cancel := context.WithCancel(ctx)
220 r.activeCancel = cancel
221 r.mu.Unlock()
222
223 imgBlocks := make([]provider.ImageBlock, 0, len(images))
224 for _, img := range images {
225 imgBlocks = append(imgBlocks, provider.ImageBlock{MimeType: img.MimeType, Data: img.Data})
226 }
227
228 out := make(chan Event, 16)
229 go func() {
230 defer close(out)
231 defer func() {
232 r.mu.Lock()
233 r.activeCancel = nil
234 r.mu.Unlock()
235 }()
236 err := r.agent.Prompt(subCtx, text, imgBlocks, func(ev core.AgentEvent) {
237 out <- toEvent(ev)
238 })
239 if err != nil && !errors.Is(err, context.Canceled) {
240 out <- Event{Type: "error", Error: err.Error()}
241 }
242 }()
243 return out, nil
244}
245
246// Cancel interrupts the active Prompt, if any. Safe to call when no
247// prompt is in flight (no-op).
248func (r *Runtime) Cancel() {
249 r.mu.Lock()
250 cancel := r.activeCancel
251 r.mu.Unlock()
252 if cancel != nil {
253 cancel()
254 }
255}
256
257// Compact summarises the current transcript into a single synthetic
258// user message, freeing up context. Blocks until done.
259func (r *Runtime) Compact(ctx context.Context, customInstructions string) (CompactResult, error) {
260 if r.agent == nil {
261 return CompactResult{}, fmt.Errorf("sdk: no agent")
262 }
263 r.mu.Lock()
264 if r.activeCancel != nil {
265 r.mu.Unlock()
266 return CompactResult{}, ErrBusy
267 }
268 subCtx, cancel := context.WithCancel(ctx)
269 r.activeCancel = cancel
270 r.mu.Unlock()
271 defer func() {
272 r.mu.Lock()
273 r.activeCancel = nil
274 r.mu.Unlock()
275 }()
276
277 summary, err := r.agent.Compact(subCtx, 4, nil)
278 if err != nil {
279 return CompactResult{}, err
280 }
281 return CompactResult{Summary: summary, Messages: r.Messages()}, nil
282}
283
284// SetModel switches the active model. Same provider only; for cross-
285// provider switches, create a new Runtime.
286func (r *Runtime) SetModel(model string) error {
287 r.mu.Lock()
288 defer r.mu.Unlock()
289 if r.agent == nil {
290 return fmt.Errorf("sdk: no agent")
291 }
292 if _, err := provider.FindModel(r.provider, model); err != nil {
293 return err
294 }
295 r.agent.Model = model
296 r.model = model
297 return nil
298}
299
300// SetReasoning changes the reasoning level used by subsequent prompts.
301func (r *Runtime) SetReasoning(level string) error {
302 r.mu.Lock()
303 defer r.mu.Unlock()
304 if r.agent == nil {
305 return fmt.Errorf("sdk: no agent")
306 }
307 reasoning := provider.NormalizeReasoning(level)
308 switch reasoning {
309 case "", "minimum", "low", "medium", "high", "xhigh", "max":
310 r.agent.Reasoning = reasoning
311 return nil
312 default:
313 return fmt.Errorf("sdk: reasoning must be off|minimum|low|medium|high|xhigh|max")
314 }
315}
316
317// State returns a snapshot of the runtime's current state. Useful for
318// driving UIs.
319func (r *Runtime) State() State {
320 r.mu.Lock()
321 defer r.mu.Unlock()
322 return State{
323 Provider: r.provider,
324 Model: r.model,
325 Reasoning: r.agent.Reasoning,
326 CWD: r.cwd,
327 Busy: r.activeCancel != nil,
328 MessageCount: len(r.Messages()),
329 }
330}
331
332// Close releases resources held by the Runtime. Cancels any active
333// prompt. Subsequent calls return ErrClosed.
334func (r *Runtime) Close() error {
335 r.mu.Lock()
336 if r.closed {
337 r.mu.Unlock()
338 return nil
339 }
340 r.closed = true
341 cancel := r.activeCancel
342 r.mu.Unlock()
343 if cancel != nil {
344 cancel()
345 }
346 return nil
347}
348
349// ListModels returns every model known to the runtime for the
350// current provider (catalog + live discovery if cached).
351func (r *Runtime) ListModels() []ModelInfo {
352 models := provider.ModelsForProvider(r.Provider())
353 out := make([]ModelInfo, 0, len(models))
354 for _, m := range models {
355 out = append(out, ModelInfo{
356 ID: m.ID,
357 Provider: m.Provider,
358 ContextWindow: m.ContextWindow,
359 MaxOutput: m.MaxOutput,
360 Reasoning: m.Reasoning,
361 })
362 }
363 return out
364}
365
366// ---- public errors ----
367
368// ErrBusy is returned when a Prompt or Compact is started while
369// another is in flight on the same Runtime.
370var ErrBusy = errors.New("sdk: runtime is busy")
371
372// ErrClosed is returned by methods on a Runtime after Close.
373var ErrClosed = errors.New("sdk: runtime is closed")