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