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