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