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