v0.29.1 d92a60f

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

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