v0.3.1 5c1a9ec

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

↑↓ moveenter openesc close