lib/acp/src/protocol.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 //! Zig shapes for the Agent Client Protocol messages the client reads and writes: the agent's
2 //! answer to `initialize`, the session's modes, the blocks a prompt carries, the progress reports
3 //! the agent sends, its permission requests, and the result of a prompt.
4 //!
5 //! A caller has to read what the agent reported without walking the JSON itself, and it has to keep
6 //! that data after the parsed message is gone.
7 //!
8 //! The messages carry many optional fields, and an agent can send kinds of progress report
9 //! unfamiliar to the code.
10 //!
11 //! The fields follow the protocol's JSON keys, such as `protocolVersion`, `agentCapabilities`,
12 //! `sessionUpdate`, `toolCall` and `stopReason`. Prompt blocks are written with the protocol's type
13 //! names `text`, `image`, `audio`, `resource` and `resource_link`.
14 //!
15 //! Each parsed shape copies the strings it keeps with the caller's allocator, so it outlives the
16 //! parsed JSON, and its `deinit` frees them. Each parsed shape breaks out a few fields as typed
17 //! values and keeps a minified JSON copy (*raw copy*) of the rest of its part of the message. A
18 //! missing optional string reads as an empty slice, a missing flag as false, a missing list as a
19 //! count of 0, and a progress report of an unknown kind as `other`. When the client writes a prompt
20 //! block, it leaves out every optional string that is empty. The one borrowed field is the exact
21 //! line the agent sent (*envelope*), in `Update` and `PermissionRequest`: it points into the
22 //! reader's buffer and is valid only while the caller's callback runs.
23 //!
24 //! - *observer*: the caller's callbacks for updates and permission decisions
25 //! - *summary text*: one short string picked from an update's fields
26 //! - *transport epoch*: a nonzero number the caller picks for one client
27 //! - *update sequence*: an update's number, counted from 1 in arrival order
28 //! - *overflow*: a record that a reply stopped at a limit
29 //! - *permission policy*: two caller lists of tool kinds and title prefixes to allow
30 const std = @import("std");
31 const pretty = @import("pretty");
32 const json = @import("json.zig");
33
34 const Allocator = std.mem.Allocator;
35 const pretty_json = pretty.json;
36
37 /// A caller uses this structure to learn which protocol version the agent speaks, which kinds of
38 /// prompt block it accepts, and how it authenticates. The structure holds the agent's answer to
39 /// `initialize`: protocol version, capability flags, authentication methods, and a raw copy of the
40 /// whole result. `Client.start` fills the structure and keeps it in `Client.initialize`. Every
41 /// field has a default, and the default value stands for an absent answer. The structure owns its
42 /// two strings, and `deinit` frees them.
43 pub const Initialize = struct {
44 /// The protocol version the agent answered with, from `result.protocolVersion`. `Client.start`
45 /// accepts only 1. The field defaults to 0.
46 protocol_version: i64 = 0,
47 /// The agent's `agentCapabilities.loadSession` flag. The value is false when the key is absent.
48 load_session: bool = false,
49 /// The agent's `promptCapabilities.audio` flag, for prompts that carry audio blocks. The value
50 /// is false when the key is absent.
51 prompt_audio: bool = false,
52 /// The agent's `promptCapabilities.image` flag, for prompts that carry image blocks. The value
53 /// is false when the key is absent.
54 prompt_image: bool = false,
55 /// Reports true when `promptCapabilities` sets either `embeddedContent` or `embeddedContext` to
56 /// true. The value is false when both keys are absent.
57 prompt_embedded_content: bool = false,
58 /// The number of entries in the answer's `authMethods` list. The value is 0 when the key is
59 /// absent or holds a non-list value.
60 auth_methods: usize = 0,
61 /// The ids of the authentication methods as one minified JSON array of strings, such as
62 /// `["token"]`. A method lacking a string `id` is skipped, and the slice is empty when all
63 /// methods lack one. The slice is owned.
64 auth_method_ids: []u8 = &.{},
65 /// A minified JSON copy of the whole `result` object. The slice is owned.
66 raw: []u8 = &.{},
67
68 /// A caller uses this function to free the answer once the caller is done with it. The function
69 /// frees the two owned strings with the given allocator and leaves the value undefined. The
70 /// call is safe on the default value, which owns zero allocations. The caller provides the
71 /// allocator that `fromResponse` used.
72 pub fn deinit(self: *Initialize, allocator: Allocator) void {
73 if (self.auth_method_ids.len != 0) allocator.free(self.auth_method_ids);
74 if (self.raw.len != 0) allocator.free(self.raw);
75 self.* = undefined;
76 }
77
78 /// A caller uses this function to turn a parsed `initialize` response into typed fields, as
79 /// `Client.start` does. The function reads a parsed JSON-RPC response and returns its result as
80 /// an `Initialize`. The function fails with `error.AgentProtocolError` when the value is a
81 /// non-object, carries an `error` member, lacks an object `result`, or the result lacks an
82 /// integer `protocolVersion`. The parser reads a missing capability flag as false. The call
83 /// copies the strings it keeps with the allocator, so the result outlives the parsed JSON, and
84 /// the caller frees it with `deinit`. The function fails with `error.OutOfMemory` and leaves
85 /// zero allocations when a copy fails.
86 pub fn fromResponse(allocator: Allocator, value: std.json.Value) !Initialize {
87 const result = try responseResult(value);
88 const protocol_version = json.objectInteger(result, "protocolVersion") orelse return error.AgentProtocolError;
89 const raw = try rawJsonAlloc(allocator, result);
90 errdefer if (raw.len != 0) allocator.free(raw);
91 const auth_method_ids = try authMethodIdsAlloc(allocator, result);
92 errdefer if (auth_method_ids.len != 0) allocator.free(auth_method_ids);
93 var load_session = false;
94 var prompt_audio = false;
95 var prompt_image = false;
96 var prompt_embedded_content = false;
97 if (json.objectObject(result, "agentCapabilities")) |capabilities| {
98 load_session = json.objectBool(capabilities, "loadSession") orelse false;
99 if (json.objectObject(capabilities, "promptCapabilities")) |prompt| {
100 prompt_audio = json.objectBool(prompt, "audio") orelse false;
101 prompt_image = json.objectBool(prompt, "image") orelse false;
102 prompt_embedded_content = (json.objectBool(prompt, "embeddedContent") orelse false) or (json.objectBool(prompt, "embeddedContext") orelse false);
103 }
104 }
105 return .{
106 .protocol_version = protocol_version,
107 .load_session = load_session,
108 .prompt_audio = prompt_audio,
109 .prompt_image = prompt_image,
110 .prompt_embedded_content = prompt_embedded_content,
111 .auth_methods = arrayLength(result, "authMethods"),
112 .auth_method_ids = auth_method_ids,
113 .raw = raw,
114 };
115 }
116 };
117
118 /// A caller uses this structure to learn which mode the session starts in, such as `plan` or `act`,
119 /// and how many modes the agent offers. The structure holds the session's current mode id, the
120 /// number of modes on offer, and a raw copy of the `modes` object from the `session/new` answer.
121 /// `Client.start` keeps the structure in `Client.modes`, and the value stays empty when the agent
122 /// reports zero modes. The structure owns `current` and `raw`, and `deinit` frees them.
123 pub const Modes = struct {
124 /// The current mode's id, from `modes.currentModeId`. The slice is owned, and empty by default.
125 current: []u8 = &.{},
126 /// The number of entries in `modes.availableModes`. The count is 0 by default and when the key
127 /// is absent.
128 available: usize = 0,
129 /// A minified JSON copy of the `modes` object. The slice is owned, and empty by default.
130 raw: []u8 = &.{},
131
132 /// A caller uses this function to free the modes once the caller is done with them. The
133 /// function frees the two owned strings with the given allocator and leaves the value
134 /// undefined. The call is safe on the default value, which owns zero allocations.
135 pub fn deinit(self: *Modes, allocator: Allocator) void {
136 if (self.current.len != 0) allocator.free(self.current);
137 if (self.raw.len != 0) allocator.free(self.raw);
138 self.* = undefined;
139 }
140
141 /// A caller uses this function to read the session's modes from a parsed `session/new`
142 /// response, as `Client.start` does. The function reads a parsed JSON-RPC response and returns
143 /// the modes in its result. The function returns null when the result lacks a `modes` object or
144 /// that object lacks a string `currentModeId`. The function fails with
145 /// `error.AgentProtocolError` when the value is a non-object, carries an `error` member, or
146 /// lacks an object `result`. The call copies the strings it keeps with the allocator, and the
147 /// caller frees them with `deinit`.
148 pub fn fromResponse(allocator: Allocator, value: std.json.Value) !?Modes {
149 const result = try responseResult(value);
150 const modes = json.objectObject(result, "modes") orelse return null;
151 const current = json.objectString(modes, "currentModeId") orelse return null;
152 const owned_current = try allocator.dupe(u8, current);
153 errdefer allocator.free(owned_current);
154 const raw = try rawJsonAlloc(allocator, modes);
155 errdefer if (raw.len != 0) allocator.free(raw);
156 return .{
157 .current = owned_current,
158 .available = arrayLength(modes, "availableModes"),
159 .raw = raw,
160 };
161 }
162 };
163
164 /// A caller uses this structure to put an image or an audio clip in a prompt. The structure holds
165 /// an image or an audio clip for a prompt: its MIME type, its data, and an optional URI. The client
166 /// writes it as a block of type `image` or `audio` with `mimeType`, `data`, and `uri` when one is
167 /// set. The strings are borrowed and read only while the prompt is written.
168 pub const PromptMedia = struct {
169 /// The media's MIME type, sent as `mimeType`. The field is required.
170 mime_type: []const u8,
171 /// The media's content, sent unchanged as the JSON string `data`. The field is required.
172 data: []const u8,
173 /// The media's URI, sent as `uri`. The field is empty by default, and an empty URI is left out.
174 uri: []const u8 = "",
175 };
176
177 /// A caller uses this structure to put a file's text in the prompt, such as source code the agent
178 /// should read. The structure holds a text resource embedded in a prompt: its URI, its text, and an
179 /// optional MIME type. The client writes it as a block of type `resource` whose `resource` object
180 /// holds `uri`, `text`, and `mimeType` when one is set.
181 pub const PromptTextResource = struct {
182 /// The resource's URI, sent as `resource.uri`. The field is required.
183 uri: []const u8,
184 /// The resource's text, sent as `resource.text`. The field is required.
185 text: []const u8,
186 /// The resource's MIME type, sent as `resource.mimeType`. The field is empty by default, and an
187 /// empty value is left out.
188 mime_type: []const u8 = "",
189 };
190
191 /// A caller uses this structure to put a resource's content in the prompt as a blob string. The
192 /// structure holds a blob resource embedded in a prompt: its URI, its content as a string, and an
193 /// optional MIME type. The client writes it as a block of type `resource` whose `resource` object
194 /// holds `uri`, `blob`, and `mimeType` when one is set.
195 pub const PromptBlobResource = struct {
196 /// The resource's URI, sent as `resource.uri`. The field is required.
197 uri: []const u8,
198 /// The resource's content, sent unchanged as the JSON string `resource.blob`. The field is
199 /// required.
200 blob: []const u8,
201 /// The resource's MIME type, sent as `resource.mimeType`. The field is empty by default, and an
202 /// empty value is left out.
203 mime_type: []const u8 = "",
204 };
205
206 /// A caller uses this structure to point the agent at a resource by URI without putting its content
207 /// in the prompt. The structure holds a link to a resource: a URI and a name, with an optional MIME
208 /// type, title, description and size. The client writes it as a block of type `resource_link` with
209 /// each field that is set. Empty strings and a null size are left out.
210 pub const PromptResourceLink = struct {
211 /// The resource's URI, sent as `uri`. The field is required.
212 uri: []const u8,
213 /// The resource's name, sent as `name`. The field is required.
214 name: []const u8,
215 /// The resource's MIME type, sent as `mimeType`. The field is empty by default, and an empty
216 /// value is left out.
217 mime_type: []const u8 = "",
218 /// A title for the resource, sent as `title`. The field is empty by default, and an empty value
219 /// is left out.
220 title: []const u8 = "",
221 /// A description of the resource, sent as `description`. The field is empty by default, and an
222 /// empty value is left out.
223 description: []const u8 = "",
224 /// The resource's size, sent as the number `size`. The field is null by default, and a null
225 /// size is left out.
226 size: ?u64 = null,
227 };
228
229 /// A prompt is a list of these blocks, so a caller can mix text with files, links and media in one
230 /// prompt. The union holds one block of prompt content, of one of six kinds.
231 /// `Client.promptDetailedContent` sends a list of blocks in order. `Client.prompt` and
232 /// `Client.promptDetailed` send a single text block. The client writes every kind as given, without
233 /// checking the capability flags in `Initialize`, so the caller checks them.
234 pub const PromptContent = union(enum) {
235 /// Plain text, written as a block of type `text` with the string in `text`.
236 text: []const u8,
237 /// An image, written as a block of type `image`.
238 image: PromptMedia,
239 /// An audio clip, written as a block of type `audio`.
240 audio: PromptMedia,
241 /// A text resource embedded in the prompt, written as a block of type `resource`.
242 resource_text: PromptTextResource,
243 /// A blob resource embedded in the prompt, written as a block of type `resource`.
244 resource_blob: PromptBlobResource,
245 /// A link to a resource, written as a block of type `resource_link`.
246 resource_link: PromptResourceLink,
247 };
248
249 /// The kind of one progress report (session update), read from the report's `sessionUpdate` name,
250 /// so the caller switches on the kind of each progress report the agent sends. Eleven tags carry
251 /// the eleven names the package test replays, one each, and every other name maps to `other`.
252 pub const UpdateKind = enum {
253 /// The name `user_message_chunk`: a piece of a user message, with its text in `content.text`.
254 user_message_chunk,
255 /// The name `agent_message_chunk`: a piece of the agent's reply, with its text in
256 /// `content.text`. This tag is the only kind whose text the client adds to the reply.
257 agent_message_chunk,
258 /// The name `agent_thought_chunk`: a piece of the agent's thinking, with its text in
259 /// `content.text`.
260 agent_thought_chunk,
261 /// The name `tool_call`: a new tool call, with `toolCallId`, `title`, `kind` and `status`.
262 tool_call,
263 /// The name `tool_call_update`: a change to a tool call, with its `toolCallId` and new
264 /// `status`.
265 tool_call_update,
266 /// The name `plan`: the agent's plan, a list of `entries` each with `content` and `status`.
267 plan,
268 /// The name `available_commands_update`: the commands the agent offers, in `availableCommands`.
269 available_commands_update,
270 /// The name `current_mode_update`: the session's mode changed, with the new id in
271 /// `currentModeId`.
272 current_mode_update,
273 /// The name `config_option_update`: a change to the agent's `configOptions` list.
274 config_option_update,
275 /// The name `session_info_update`: session details such as `title` and `updatedAt`.
276 session_info_update,
277 /// The name `usage_update`: usage figures in `used` and `size`.
278 usage_update,
279 /// Any other name, which stays in `Update.name`.
280 other,
281 };
282
283 /// One `session/update` notification from the agent, with the two numbers the client gave it, the
284 /// exact line, and fields read from it, received by the observer so the caller records and inspects
285 /// the agent's progress. The client builds one structure per notification, hands it to the
286 /// observer, and frees it when the callback returns. The structure owns every `[]u8` field, and
287 /// `deinit` frees them. A field the notification lacks is empty, null or 0.
288 pub const Update = struct {
289 /// The caller's number for the client that received the notification.
290 transport_epoch: u64,
291 /// The notification's place among all the notifications the client has received, counting
292 /// from 1.
293 update_sequence: u64,
294 /// The exact line the agent sent, trimmed of surrounding whitespace. The client trims spaces,
295 /// tabs, carriage returns and newlines from both ends of the line. This slice is borrowed from
296 /// the reader's buffer and remains valid only until the observer's callback returns. The
297 /// `deinit` function leaves it alone.
298 envelope: []const u8,
299 /// The kind read from `sessionUpdate`.
300 kind: UpdateKind,
301 /// The `sessionUpdate` string as the agent sent it. The update owns this field.
302 name: []u8,
303 /// The notification's `params.sessionId`, empty when absent. The update owns this field.
304 session_id: []u8,
305 /// A summary text for the notification. For an `agent_message_chunk`, this field carries the
306 /// chunk's text, and the client adds it to the reply. The client picks the first of these the
307 /// update carries: the content text, the title, the mode id, the first plan entry's content,
308 /// the first command name, the usage as "used/size", and else the update's name. The update
309 /// owns this field.
310 text: []u8,
311 /// A minified JSON copy of the `params.update` object. The update owns this field.
312 raw: []u8,
313 /// The update's `toolCallId`, empty when absent. The update owns this field.
314 tool_call_id: []u8 = &.{},
315 /// The update's `status`, empty when absent. The update owns this field.
316 status: []u8 = &.{},
317 /// The update's `kind`, the tool call's kind, empty when absent. The update owns this field.
318 tool_kind: []u8 = &.{},
319 /// The update's `currentModeId`, empty when absent. The update owns this field.
320 mode_id: []u8 = &.{},
321 /// The update's integer `used`, or null when absent.
322 usage_used: ?i64 = null,
323 /// The update's integer `size`, or null when absent.
324 usage_size: ?i64 = null,
325 /// The number of entries in the update's `entries` list, 0 when absent.
326 plan_entries: usize = 0,
327 /// The number of entries in the update's `availableCommands` list, 0 when absent.
328 available_commands: usize = 0,
329 /// The number of entries in the update's `configOptions` list, 0 when absent.
330 config_options: usize = 0,
331 /// The update's `title`, empty when absent. The update owns this field.
332 title: []u8 = &.{},
333 /// The update's `updatedAt`, empty when absent. The update owns this field.
334 updated_at: []u8 = &.{},
335
336 /// Frees every owned string with the given allocator and leaves the value undefined, so the
337 /// caller frees an update built with `fromSessionNotification`. The call leaves the borrowed
338 /// line alone.
339 pub fn deinit(self: *Update, allocator: Allocator) void {
340 if (self.name.len != 0) allocator.free(self.name);
341 if (self.session_id.len != 0) allocator.free(self.session_id);
342 if (self.text.len != 0) allocator.free(self.text);
343 if (self.raw.len != 0) allocator.free(self.raw);
344 if (self.tool_call_id.len != 0) allocator.free(self.tool_call_id);
345 if (self.status.len != 0) allocator.free(self.status);
346 if (self.tool_kind.len != 0) allocator.free(self.tool_kind);
347 if (self.mode_id.len != 0) allocator.free(self.mode_id);
348 if (self.title.len != 0) allocator.free(self.title);
349 if (self.updated_at.len != 0) allocator.free(self.updated_at);
350 self.* = undefined;
351 }
352
353 /// Reads `params.update` from a parsed notification object, so the caller turns one parsed
354 /// `session/update` notification into an `Update`, as the client does for each one it reads.
355 /// The call returns null when `params`, `params.update` or its `sessionUpdate` string is
356 /// missing, and the client then fails its call with `error.AgentProtocolError`. The function
357 /// stores the given epoch, sequence and line, and keeps the line by reference. The call copies
358 /// every string it keeps with the allocator, and the caller frees them with `deinit`. The
359 /// function fails with `error.OutOfMemory` and leaves nothing allocated when a copy fails. The
360 /// call reads the object as given and does not look at `method`, because the client checks the
361 /// method name before it calls this.
362 pub fn fromSessionNotification(
363 allocator: Allocator,
364 object: std.json.ObjectMap,
365 transport_epoch: u64,
366 update_sequence: u64,
367 envelope: []const u8,
368 ) !?Update {
369 const params = json.objectObject(object, "params") orelse return null;
370 const update = json.objectObject(params, "update") orelse return null;
371 const name = json.objectString(update, "sessionUpdate") orelse return null;
372 const session_id = json.objectString(params, "sessionId") orelse "";
373 const owned_name = try allocator.dupe(u8, name);
374 errdefer allocator.free(owned_name);
375 const owned_session = try allocator.dupe(u8, session_id);
376 errdefer allocator.free(owned_session);
377 const text = try summaryTextAlloc(allocator, update, name);
378 errdefer if (text.len != 0) allocator.free(text);
379 const raw = try rawJsonAlloc(allocator, update);
380 errdefer if (raw.len != 0) allocator.free(raw);
381 const tool_call_id = try optionalStringAlloc(allocator, update, "toolCallId");
382 errdefer if (tool_call_id.len != 0) allocator.free(tool_call_id);
383 const status = try optionalStringAlloc(allocator, update, "status");
384 errdefer if (status.len != 0) allocator.free(status);
385 const tool_kind = try optionalStringAlloc(allocator, update, "kind");
386 errdefer if (tool_kind.len != 0) allocator.free(tool_kind);
387 const mode_id = try optionalStringAlloc(allocator, update, "currentModeId");
388 errdefer if (mode_id.len != 0) allocator.free(mode_id);
389 const title = try optionalStringAlloc(allocator, update, "title");
390 errdefer if (title.len != 0) allocator.free(title);
391 const updated_at = try optionalStringAlloc(allocator, update, "updatedAt");
392 errdefer if (updated_at.len != 0) allocator.free(updated_at);
393 return .{
394 .transport_epoch = transport_epoch,
395 .update_sequence = update_sequence,
396 .envelope = envelope,
397 .kind = kindFromName(name),
398 .name = owned_name,
399 .session_id = owned_session,
400 .text = text,
401 .raw = raw,
402 .tool_call_id = tool_call_id,
403 .status = status,
404 .tool_kind = tool_kind,
405 .mode_id = mode_id,
406 .usage_used = json.objectInteger(update, "used"),
407 .usage_size = json.objectInteger(update, "size"),
408 .plan_entries = arrayLength(update, "entries"),
409 .available_commands = arrayLength(update, "availableCommands"),
410 .config_options = arrayLength(update, "configOptions"),
411 .title = title,
412 .updated_at = updated_at,
413 };
414 }
415
416 /// Returns true for an `agent_message_chunk`, the only kind the client adds to a reply, so the
417 /// caller tells whether an update carries reply text.
418 pub fn assistantMessage(self: Update) bool {
419 return self.kind == .agent_message_chunk;
420 }
421 };
422
423 /// The reply limit a prompt reached.
424 pub const OverflowKind = enum {
425 /// The reply text would have passed `TransferLimits.response_bytes`.
426 response_bytes,
427 /// The number of chunks would have passed `TransferLimits.response_segment_count`.
428 response_segments,
429 };
430
431 /// Records what a reply kept when one more chunk would have passed a limit, and which update the
432 /// client left out, so the caller tells a cut reply from a whole one and sees where the reply
433 /// stopped. The client sets this record at most once per prompt, and the reply stops taking text
434 /// after it. `PromptResult.overflow` carries the record. The fields name the limit reached, the
435 /// reply bytes and chunks kept before the limit, and the update sequence of the first chunk left
436 /// out.
437 pub const Overflow = struct {
438 /// The limit the reply reached.
439 kind: OverflowKind,
440 /// The reply bytes kept before the limit.
441 admitted_bytes: usize,
442 /// The chunks kept before the limit.
443 admitted_segments: usize,
444 /// The update sequence of the first chunk left out of the reply. The observer still received
445 /// that update.
446 withheld_update_sequence: u64,
447 };
448
449 /// One `session/request_permission` request from the agent: the tool call it names, the decision,
450 /// and the exact line. The caller records what the agent asked for and what was answered because
451 /// the observer receives each permission request and its decision as one of these records. The
452 /// client builds one twice per request: a preview for `permissionFn` with outcome `pending`, empty
453 /// option fields, and an empty line, then the full record for `permissionCommitFn`. The record owns
454 /// every `[]u8` field, and `deinit` frees them.
455 pub const PermissionRequest = struct {
456 /// The exact line the agent sent, trimmed of surrounding whitespace, and empty in the preview.
457 /// The line is borrowed from the reader's buffer and remains valid only until
458 /// `permissionCommitFn` returns.
459 envelope: []const u8,
460 /// The request's `params.sessionId`, empty when absent.
461 session_id: []u8,
462 /// The tool call's `toolCallId`, empty when absent.
463 tool_call_id: []u8,
464 /// The tool call's `title`, empty when absent. The permission policy matches its title prefixes
465 /// against it.
466 title: []u8,
467 /// The tool call's `kind`, empty when the tool call omits a kind.
468 tool_kind: []u8,
469 /// The tool call's `status`, empty when absent.
470 status: []u8,
471 /// The id of the chosen option, empty when the outcome is `cancelled` and in the preview.
472 option_id: []u8,
473 /// The kind of the chosen option, such as `allow_once` or `reject_once`, empty when an option
474 /// remains unchosen.
475 option_kind: []u8,
476 /// The decision: `selected`, `cancelled`, or `pending` in the preview.
477 outcome: []u8,
478 /// The number of options the agent offered. The field defaults to 0.
479 options: usize = 0,
480 /// A minified JSON copy of the request's `params` object.
481 raw: []u8,
482
483 /// Frees every owned string with the given allocator and leaves the value undefined for a
484 /// record built with `fromClientRequest`. The call leaves the borrowed line alone.
485 pub fn deinit(self: *PermissionRequest, allocator: Allocator) void {
486 if (self.session_id.len != 0) allocator.free(self.session_id);
487 if (self.tool_call_id.len != 0) allocator.free(self.tool_call_id);
488 if (self.title.len != 0) allocator.free(self.title);
489 if (self.tool_kind.len != 0) allocator.free(self.tool_kind);
490 if (self.status.len != 0) allocator.free(self.status);
491 if (self.option_id.len != 0) allocator.free(self.option_id);
492 if (self.option_kind.len != 0) allocator.free(self.option_kind);
493 if (self.outcome.len != 0) allocator.free(self.outcome);
494 if (self.raw.len != 0) allocator.free(self.raw);
495 self.* = undefined;
496 }
497
498 /// Turns one parsed permission request and its decision into a `PermissionRequest` by reading
499 /// `params` and `params.toolCall` from a parsed request object and adding the given decision
500 /// and line, as the client does for the preview and the record. The call fails with
501 /// `error.AgentProtocolError` when `params` or `params.toolCall` is missing. The function
502 /// copies every string it keeps with the allocator, keeps the line by reference, and the caller
503 /// frees the rest with `deinit`. The call fails with `error.OutOfMemory` and leaves nothing
504 /// allocated when a copy fails.
505 pub fn fromClientRequest(
506 allocator: Allocator,
507 object: std.json.ObjectMap,
508 envelope: []const u8,
509 outcome: []const u8,
510 option_id: []const u8,
511 option_kind: []const u8,
512 ) !PermissionRequest {
513 const params = json.objectObject(object, "params") orelse return error.AgentProtocolError;
514 const tool_call = json.objectObject(params, "toolCall") orelse return error.AgentProtocolError;
515 const session_id = try optionalStringAlloc(allocator, params, "sessionId");
516 errdefer if (session_id.len != 0) allocator.free(session_id);
517 const tool_call_id = try optionalStringAlloc(allocator, tool_call, "toolCallId");
518 errdefer if (tool_call_id.len != 0) allocator.free(tool_call_id);
519 const title = try optionalStringAlloc(allocator, tool_call, "title");
520 errdefer if (title.len != 0) allocator.free(title);
521 const tool_kind = try optionalStringAlloc(allocator, tool_call, "kind");
522 errdefer if (tool_kind.len != 0) allocator.free(tool_kind);
523 const status = try optionalStringAlloc(allocator, tool_call, "status");
524 errdefer if (status.len != 0) allocator.free(status);
525 const owned_option_id = try allocator.dupe(u8, option_id);
526 errdefer allocator.free(owned_option_id);
527 const owned_option_kind = try allocator.dupe(u8, option_kind);
528 errdefer allocator.free(owned_option_kind);
529 const owned_outcome = try allocator.dupe(u8, outcome);
530 errdefer allocator.free(owned_outcome);
531 const raw = try rawJsonAlloc(allocator, params);
532 errdefer if (raw.len != 0) allocator.free(raw);
533 return .{
534 .envelope = envelope,
535 .session_id = session_id,
536 .tool_call_id = tool_call_id,
537 .title = title,
538 .tool_kind = tool_kind,
539 .status = status,
540 .option_id = owned_option_id,
541 .option_kind = owned_option_kind,
542 .outcome = owned_outcome,
543 .options = arrayLength(params, "options"),
544 .raw = raw,
545 };
546 }
547 };
548
549 /// The caller gets the reply with the reason the agent stopped and whether the reply was cut short
550 /// because `Client.promptDetailed` and `Client.promptDetailedContent` return one of these results.
551 /// The result holds the reply text, the stop reason, and an optional overflow record. The result
552 /// owns the reply and the stop reason, and `deinit` frees them.
553 pub const PromptResult = struct {
554 /// The reply: the text of the agent's message chunks, joined in arrival order, within the reply
555 /// limits. The result owns this slice.
556 response: []u8,
557 /// The `stopReason` string from the prompt's response, such as `end_turn` or `cancelled`. The
558 /// result owns this slice.
559 stop_reason: []u8,
560 /// The overflow record set when the reply reached a limit, and null otherwise.
561 overflow: ?Overflow = null,
562
563 /// Frees the reply and the stop reason with the given allocator and leaves the value undefined
564 /// once the caller is done with a prompt result. The call takes the client's allocator.
565 pub fn deinit(self: *PromptResult, allocator: Allocator) void {
566 if (self.response.len != 0) allocator.free(self.response);
567 if (self.stop_reason.len != 0) allocator.free(self.stop_reason);
568 self.* = undefined;
569 }
570 };
571
572 fn responseResult(value: std.json.Value) !std.json.ObjectMap {
573 const object = json.getObject(value) orelse return error.AgentProtocolError;
574 if (object.get("error") != null) return error.AgentProtocolError;
575 const result = object.get("result") orelse return error.AgentProtocolError;
576 return json.getObject(result) orelse return error.AgentProtocolError;
577 }
578
579 fn arrayLength(object: std.json.ObjectMap, key: []const u8) usize {
580 const value = object.get(key) orelse return 0;
581 return switch (value) {
582 .array => |array| array.items.len,
583 else => 0,
584 };
585 }
586
587 fn kindFromName(name: []const u8) UpdateKind {
588 if (std.mem.eql(u8, name, "user_message_chunk")) return .user_message_chunk;
589 if (std.mem.eql(u8, name, "agent_message_chunk")) return .agent_message_chunk;
590 if (std.mem.eql(u8, name, "agent_thought_chunk")) return .agent_thought_chunk;
591 if (std.mem.eql(u8, name, "tool_call")) return .tool_call;
592 if (std.mem.eql(u8, name, "tool_call_update")) return .tool_call_update;
593 if (std.mem.eql(u8, name, "plan")) return .plan;
594 if (std.mem.eql(u8, name, "available_commands_update")) return .available_commands_update;
595 if (std.mem.eql(u8, name, "current_mode_update")) return .current_mode_update;
596 if (std.mem.eql(u8, name, "config_option_update")) return .config_option_update;
597 if (std.mem.eql(u8, name, "session_info_update")) return .session_info_update;
598 if (std.mem.eql(u8, name, "usage_update")) return .usage_update;
599 return .other;
600 }
601
602 fn summaryTextAlloc(allocator: Allocator, update: std.json.ObjectMap, fallback: []const u8) ![]u8 {
603 if (contentText(update)) |text| return try allocator.dupe(u8, text);
604 if (json.objectString(update, "title")) |title| return try allocator.dupe(u8, title);
605 if (json.objectString(update, "currentModeId")) |mode| return try allocator.dupe(u8, mode);
606 if (planText(update)) |text| return try allocator.dupe(u8, text);
607 if (commandText(update)) |text| return try allocator.dupe(u8, text);
608 if (try usageTextAlloc(allocator, update)) |text| return text;
609 return try allocator.dupe(u8, fallback);
610 }
611
612 fn contentText(update: std.json.ObjectMap) ?[]const u8 {
613 const content = json.objectObject(update, "content") orelse return null;
614 return json.objectString(content, "text");
615 }
616
617 fn planText(update: std.json.ObjectMap) ?[]const u8 {
618 const entries_value = update.get("entries") orelse return null;
619 const entries = switch (entries_value) {
620 .array => |array| array,
621 else => return null,
622 };
623 if (entries.items.len == 0) return null;
624 const first = json.getObject(entries.items[0]) orelse return null;
625 return json.objectString(first, "content");
626 }
627
628 fn commandText(update: std.json.ObjectMap) ?[]const u8 {
629 const commands_value = update.get("availableCommands") orelse return null;
630 const commands = switch (commands_value) {
631 .array => |array| array,
632 else => return null,
633 };
634 if (commands.items.len == 0) return null;
635 const first = json.getObject(commands.items[0]) orelse return null;
636 return json.objectString(first, "name");
637 }
638
639 fn usageTextAlloc(allocator: Allocator, update: std.json.ObjectMap) Allocator.Error!?[]u8 {
640 const used = json.objectInteger(update, "used") orelse return null;
641 const size = json.objectInteger(update, "size") orelse return null;
642 return try std.fmt.allocPrint(allocator, "{d}/{d}", .{ used, size });
643 }
644
645 fn optionalStringAlloc(allocator: Allocator, object: std.json.ObjectMap, key: []const u8) Allocator.Error![]u8 {
646 const value = json.objectString(object, key) orelse return &.{};
647 return try allocator.dupe(u8, value);
648 }
649
650 fn authMethodIdsAlloc(allocator: Allocator, object: std.json.ObjectMap) ![]u8 {
651 const value = object.get("authMethods") orelse return &.{};
652 const methods = switch (value) {
653 .array => |array| array,
654 else => return &.{},
655 };
656 if (methods.items.len == 0) return &.{};
657 var out: std.Io.Writer.Allocating = .init(allocator);
658 errdefer out.deinit();
659 var writer = pretty_json.Writer.init(&out.writer, .minified);
660 const ids = try writer.array();
661 var written: usize = 0;
662 for (methods.items) |item| {
663 const method = json.getObject(item) orelse continue;
664 const id = json.objectString(method, "id") orelse continue;
665 try ids.element(id);
666 written += 1;
667 }
668 if (written == 0) {
669 out.deinit();
670 return &.{};
671 }
672 try ids.end();
673 return try out.toOwnedSlice();
674 }
675
676 fn rawJsonAlloc(allocator: Allocator, update: std.json.ObjectMap) ![]u8 {
677 var out: std.Io.Writer.Allocating = .init(allocator);
678 errdefer out.deinit();
679 try pretty_json.writeMinified(&out.writer, std.json.Value{ .object = update });
680 return try out.toOwnedSlice();
681 }
682
683 test "protocol parses session update metadata" {
684 const testing = std.testing;
685 const envelope =
686 \\{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc1","title":"read file","kind":"read","status":"completed","content":[]}}}
687 ;
688 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, envelope, .{});
689 defer parsed.deinit();
690 const object = json.getObject(parsed.value).?;
691 var update = (try Update.fromSessionNotification(testing.allocator, object, 4, 7, envelope)).?;
692 defer update.deinit(testing.allocator);
693 try testing.expectEqual(UpdateKind.tool_call_update, update.kind);
694 try testing.expectEqualStrings("tc1", update.tool_call_id);
695 try testing.expectEqualStrings("completed", update.status);
696 try testing.expectEqualStrings("read", update.tool_kind);
697 try testing.expectEqual(@as(u64, 4), update.transport_epoch);
698 try testing.expectEqual(@as(u64, 7), update.update_sequence);
699 try testing.expectEqualStrings(envelope, update.envelope);
700 }
701
702 test "protocol parses session update summaries" {
703 const testing = std.testing;
704 const envelope =
705 \\{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"plan","entries":[{"content":"inspect","status":"pending"}]}}}
706 ;
707 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, envelope, .{});
708 defer parsed.deinit();
709 const object = json.getObject(parsed.value).?;
710 var update = (try Update.fromSessionNotification(testing.allocator, object, 1, 1, envelope)).?;
711 defer update.deinit(testing.allocator);
712 try testing.expectEqual(UpdateKind.plan, update.kind);
713 try testing.expectEqualStrings("plan", update.name);
714 try testing.expectEqualStrings("s1", update.session_id);
715 try testing.expectEqualStrings("inspect", update.text);
716 try testing.expect(std.mem.indexOf(u8, update.raw, "\"sessionUpdate\":\"plan\"") != null);
717 }
718
719 test "protocol parses initialize capabilities" {
720 const testing = std.testing;
721 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator,
722 \\{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"audio":true,"image":true,"embeddedContext":true}},"authMethods":[{"id":"oauth\"\n"},{"id":"token"}]}}
723 , .{});
724 defer parsed.deinit();
725 var initialize = try Initialize.fromResponse(testing.allocator, parsed.value);
726 defer initialize.deinit(testing.allocator);
727 try testing.expectEqual(@as(i64, 1), initialize.protocol_version);
728 try testing.expect(initialize.load_session);
729 try testing.expect(initialize.prompt_audio);
730 try testing.expect(initialize.prompt_image);
731 try testing.expect(initialize.prompt_embedded_content);
732 try testing.expectEqual(@as(usize, 2), initialize.auth_methods);
733 try testing.expectEqualStrings("[\"oauth\\\"\\n\",\"token\"]", initialize.auth_method_ids);
734 try testing.expect(std.mem.indexOf(u8, initialize.raw, "\"loadSession\":true") != null);
735 }
736
737 test "protocol parses session modes" {
738 const testing = std.testing;
739 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator,
740 \\{"jsonrpc":"2.0","id":1,"result":{"sessionId":"s1","modes":{"currentModeId":"plan","availableModes":[{"id":"plan","name":"Plan"},{"id":"act","name":"Act"}]}}}
741 , .{});
742 defer parsed.deinit();
743 var modes = (try Modes.fromResponse(testing.allocator, parsed.value)).?;
744 defer modes.deinit(testing.allocator);
745 try testing.expectEqualStrings("plan", modes.current);
746 try testing.expectEqual(@as(usize, 2), modes.available);
747 try testing.expect(std.mem.indexOf(u8, modes.raw, "\"currentModeId\":\"plan\"") != null);
748 }