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 leaves the loop unlimited.
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 enables the ordinary
80 // built-ins: read, write, edit, bash, create_worktree, grep, lsp, update_goal,
81 // and update_plan. The public-web capability is excluded unless this list
82 // explicitly contains "web_search"; that selector enables web_search,
83 // web_open, web_find, and web_click. Pass 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 r, err := agent.Resolve(args, true)
138 if err != nil {
139 return nil, err
140 }
141 if cfg.Lock && r.Sandbox != nil {
142 r.Sandbox.Lock()
143 }
144 ag := r.NewAgent()
145 return &Runtime{
146 agent: ag,
147 provider: r.Provider,
148 model: r.Model,
149 cwd: r.CWD,
150 }, nil
151}
152
153// Provider returns the active provider id.
154func (r *Runtime) Provider() string { r.mu.Lock(); defer r.mu.Unlock(); return r.provider }
155
156// Model returns the active model id.
157func (r *Runtime) Model() string { r.mu.Lock(); defer r.mu.Unlock(); return r.model }
158
159// CWD returns the working directory the agent operates in.
160func (r *Runtime) CWD() string { r.mu.Lock(); defer r.mu.Unlock(); return r.cwd }
161
162// Messages returns a copy of the current transcript.
163func (r *Runtime) Messages() []Message {
164 if r.agent == nil {
165 return nil
166 }
167 src := r.agent.Messages()
168 out := make([]Message, len(src))
169 for i, m := range src {
170 out[i] = Message{
171 Role: string(m.Role),
172 Content: convertContent(m.Content),
173 Time: m.Time.Format("2006-01-02T15:04:05Z"),
174 }
175 }
176 return out
177}
178
179// SetMessages replaces the transcript. Use to seed history from a
180// session file or to clear with nil.
181func (r *Runtime) SetMessages(msgs []Message) {
182 if r.agent == nil {
183 return
184 }
185 out := make([]provider.Message, 0, len(msgs))
186 for _, m := range msgs {
187 out = append(out, provider.Message{
188 Role: provider.Role(m.Role),
189 Content: rebuildContent(m.Content),
190 })
191 }
192 r.agent.SetMessages(out)
193}
194
195// Cost returns the cumulative usage and dollar cost for this Runtime.
196func (r *Runtime) Cost() Usage {
197 if r.agent == nil {
198 return Usage{}
199 }
200 u := r.agent.Cost()
201 return Usage{
202 Input: u.InputTokens,
203 Output: u.OutputTokens,
204 Reasoning: sdkReasoningTokens(u),
205 CacheRead: u.CacheReadTokens,
206 CacheWrite: u.CacheWriteTokens,
207 CacheMeasuredPrompt: u.CacheMeasuredPromptTokens,
208 CacheMeasuredRead: u.CacheMeasuredReadTokens,
209 CostUSD: u.CostUSD,
210 }
211}
212
213// Prompt sends a user message and runs the agent loop. Returns a
214// channel that emits one Event per agent action, closed when the
215// turn finishes (cleanly or with error). Only one Prompt may be
216// active at a time per Runtime; concurrent calls return ErrBusy.
217func (r *Runtime) Prompt(ctx context.Context, text string, images []Image) (<-chan Event, error) {
218 r.mu.Lock()
219 if r.closed {
220 r.mu.Unlock()
221 return nil, ErrClosed
222 }
223 if r.activeCancel != nil {
224 r.mu.Unlock()
225 return nil, ErrBusy
226 }
227 if r.agent == nil {
228 r.mu.Unlock()
229 return nil, fmt.Errorf("sdk: no agent (login first via /login or set credentials)")
230 }
231 subCtx, cancel := context.WithCancel(ctx)
232 done := make(chan struct{})
233 r.activeCancel = cancel
234 r.activeDone = done
235 r.mu.Unlock()
236
237 imgBlocks := make([]provider.ImageBlock, 0, len(images))
238 for _, img := range images {
239 imgBlocks = append(imgBlocks, provider.ImageBlock{MimeType: img.MimeType, Data: img.Data})
240 }
241
242 out := make(chan Event, 16)
243 go func() {
244 defer close(out)
245 defer func() {
246 r.mu.Lock()
247 r.activeCancel = nil
248 r.activeDone = nil
249 close(done)
250 r.mu.Unlock()
251 }()
252 err := r.agent.Prompt(subCtx, text, imgBlocks, func(ev core.AgentEvent) {
253 out <- toEvent(ev)
254 })
255 if err != nil && !errors.Is(err, context.Canceled) {
256 out <- Event{Type: "error", Error: err.Error()}
257 }
258 }()
259 return out, nil
260}
261
262// Cancel interrupts the active Prompt, if any. Safe to call when no
263// prompt is in flight (no-op).
264func (r *Runtime) Cancel() {
265 r.mu.Lock()
266 cancel := r.activeCancel
267 r.mu.Unlock()
268 if cancel != nil {
269 cancel()
270 }
271}
272
273// Compact summarises the current transcript into a single synthetic
274// user message, freeing up context. Blocks until done.
275func (r *Runtime) Compact(ctx context.Context, customInstructions string) (CompactResult, error) {
276 if r.agent == nil {
277 return CompactResult{}, fmt.Errorf("sdk: no agent")
278 }
279 r.mu.Lock()
280 if r.activeCancel != nil {
281 r.mu.Unlock()
282 return CompactResult{}, ErrBusy
283 }
284 subCtx, cancel := context.WithCancel(ctx)
285 done := make(chan struct{})
286 r.activeCancel = cancel
287 r.activeDone = done
288 r.mu.Unlock()
289 defer func() {
290 r.mu.Lock()
291 r.activeCancel = nil
292 r.activeDone = nil
293 close(done)
294 r.mu.Unlock()
295 }()
296
297 summary, err := r.agent.Compact(subCtx, 4, nil)
298 if err != nil {
299 return CompactResult{}, err
300 }
301 return CompactResult{Summary: summary, Messages: r.Messages()}, nil
302}
303
304// SetModel switches the active model. Same provider only; for cross-
305// provider switches, create a new Runtime.
306func (r *Runtime) SetModel(model string) error {
307 r.mu.Lock()
308 defer r.mu.Unlock()
309 if r.agent == nil {
310 return fmt.Errorf("sdk: no agent")
311 }
312 if _, err := provider.FindModel(r.provider, model); err != nil {
313 return err
314 }
315 r.agent.Model = model
316 r.model = model
317 return nil
318}
319
320// SetReasoning changes the reasoning level used by subsequent prompts.
321func (r *Runtime) SetReasoning(level string) error {
322 r.mu.Lock()
323 defer r.mu.Unlock()
324 if r.agent == nil {
325 return fmt.Errorf("sdk: no agent")
326 }
327 reasoning := provider.NormalizeReasoning(level)
328 switch reasoning {
329 case "", "minimum", "low", "medium", "high", "xhigh", "max":
330 r.agent.Reasoning = reasoning
331 return nil
332 default:
333 return fmt.Errorf("sdk: reasoning must be off|minimum|low|medium|high|xhigh|max")
334 }
335}
336
337// State returns a snapshot of the runtime's current state. Useful for
338// driving UIs.
339func (r *Runtime) State() State {
340 r.mu.Lock()
341 defer r.mu.Unlock()
342 return State{
343 Provider: r.provider,
344 Model: r.model,
345 Reasoning: r.agent.Reasoning,
346 CWD: r.cwd,
347 Busy: r.activeCancel != nil,
348 MessageCount: len(r.Messages()),
349 }
350}
351
352// Close releases resources held by the Runtime. Cancels any active
353// prompt. Subsequent calls return ErrClosed.
354func (r *Runtime) Close() error {
355 r.mu.Lock()
356 if r.closed {
357 r.mu.Unlock()
358 return nil
359 }
360 r.closed = true
361 cancel := r.activeCancel
362 done := r.activeDone
363 r.mu.Unlock()
364 if cancel != nil {
365 cancel()
366 }
367 if done != nil {
368 <-done
369 }
370 r.mu.Lock()
371 ag := r.agent
372 r.mu.Unlock()
373 if ag != nil {
374 return agenttools.CloseLSPManagers(ag.ToolsSnapshot())
375 }
376 return nil
377}
378
379// ListModels returns every model known to the runtime for the
380// current provider (catalog + live discovery if cached).
381func (r *Runtime) ListModels() []ModelInfo {
382 models := provider.ModelsForProvider(r.Provider())
383 out := make([]ModelInfo, 0, len(models))
384 for _, m := range models {
385 out = append(out, ModelInfo{
386 ID: m.ID,
387 Provider: m.Provider,
388 ContextWindow: m.ContextWindow,
389 MaxOutput: m.MaxOutput,
390 Reasoning: m.Reasoning,
391 })
392 }
393 return out
394}
395
396// ---- public errors ----
397
398// ErrBusy is returned when a Prompt or Compact is started while
399// another is in flight on the same Runtime.
400var ErrBusy = errors.New("sdk: runtime is busy")
401
402// ErrClosed is returned by methods on a Runtime after Close.
403var ErrClosed = errors.New("sdk: runtime is closed")