v0.51.0 a9d674e

sdk.go RawHistoryFolder

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")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close