v0.41.0 6084eb1

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

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