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