返回 CodeWhale
mod.rs
根目录 / crates / protocol / src / runtime / mod.rs
1 use std::collections::BTreeMap;
2 use std::path::PathBuf;
3
4 use serde::{Deserialize, Serialize};
5 use serde_json::Value;
6
7 pub const RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION: u32 = 1;
8 pub const RUNTIME_API_VERSION: &str = "1.0";
9
10 /// Maximum JSON input (including base64 expansion) for an image turn.
11 pub const MAX_RUNTIME_IMAGE_BODY_BYTES: usize = 8 * 1024 * 1024;
12 pub const MAX_RUNTIME_IMAGES: usize = 10;
13 pub const MAX_RUNTIME_IMAGE_BYTES: usize = 4 * 1024 * 1024;
14 pub const MAX_RUNTIME_IMAGE_TOTAL_BYTES: usize = 5 * 1024 * 1024;
15
16 /// Inline bytes only: neither host paths nor remote URLs confer attachment authority.
17 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18 #[serde(rename_all = "camelCase", deny_unknown_fields)]
19 pub struct RuntimeImageInput {
20 pub mime: String,
21 pub data_base64: String,
22 }
23
24 #[derive(Debug, Clone, Serialize, Deserialize)]
25 pub struct RuntimeEventEnvelope {
26 #[serde(default = "default_runtime_event_envelope_schema_version")]
27 pub schema_version: u32,
28 pub seq: u64,
29 pub event: String,
30 pub kind: String,
31 pub thread_id: String,
32 pub turn_id: Option<String>,
33 pub item_id: Option<String>,
34 pub timestamp: String,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub created_at: Option<String>,
37 pub payload: Value,
38 #[serde(default)]
39 #[serde(flatten)]
40 pub extra: BTreeMap<String, Value>,
41 }
42
43 fn default_runtime_event_envelope_schema_version() -> u32 {
44 RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION
45 }
46
47 // ---------------------------------------------------------------------------
48 // Capability advertisement
49 // ---------------------------------------------------------------------------
50
51 /// Fixed capability map advertised by `GET /v1/runtime/info`.
52 ///
53 /// All fields are required on serialization so clients can rely on the shape.
54 #[derive(Debug, Clone, Serialize, Deserialize)]
55 pub struct RuntimeCapabilities {
56 #[serde(default)]
57 pub account_session: bool,
58 pub threads: bool,
59 /// Explicit per-thread shell opt-in is checked against loaded policy and
60 /// cannot broaden a conversation while it has an active turn.
61 #[serde(default)]
62 pub thread_shell_consent: bool,
63 pub turns: bool,
64 /// `POST /v1/threads/{id}/turns` accepts a durable, thread-scoped
65 /// `operation_key` and returns the original turn for exact retries.
66 #[serde(default)]
67 pub turn_operation_idempotency: bool,
68 /// Read-only exact accepted-turn lookup by thread and operation key.
69 #[serde(default)]
70 pub turn_operation_lookup: bool,
71 /// Bounded inline image inputs, persisted and replayed with their turn.
72 #[serde(default)]
73 pub turn_image_inputs: bool,
74 /// Per-turn maxOutputTokens is validated and intersected with the route ceiling.
75 #[serde(default)]
76 pub turn_output_token_limit: bool,
77 pub turn_steer: bool,
78 pub turn_interrupt: bool,
79 pub event_replay: bool,
80 pub external_tools: bool,
81 pub environments: bool,
82 pub worker_runtime: bool,
83 #[serde(default)]
84 pub fleet_run_create: bool,
85 #[serde(default)]
86 pub fleet_run_start: bool,
87 #[serde(default)]
88 pub fleet_event_replay: bool,
89 #[serde(default)]
90 pub fleet_event_stream: bool,
91 #[serde(default)]
92 pub fleet_local_target: bool,
93 /// `GET/PUT/DELETE /v1/threads/{id}/goal` and the `complete`/`block`
94 /// lifecycle actions are available.
95 #[serde(default)]
96 pub thread_goals: bool,
97 /// `GET /v1/memory` and `GET /v1/memory/{id}` are available for
98 /// bounded inspection of the native memory store. `POST /v1/memory`
99 /// and `DELETE /v1/memory` are also available (auth-gated via the
100 /// standard route layer) for lifecycle controls.
101 #[serde(default)]
102 pub memory: bool,
103 /// Whether the runtime supports create/update/enable/disable/reconnect/delete
104 /// operations on MCP server configuration via the `POST|GET|PATCH|DELETE
105 /// /v1/apps/mcp/servers` family of endpoints.
106 #[serde(default)]
107 pub mcp_server_management: bool,
108 /// Skill lifecycle operations (install, update, uninstall, trust, audit)
109 /// are available via the HTTP API.
110 #[serde(default)]
111 pub skill_lifecycle: bool,
112 /// Plugin bundle and marketplace lifecycle operations (list/detail,
113 /// install/update/uninstall, trust/enable/disable/revoke, marketplace
114 /// add/remove/install) are available via the `/v1/apps/plugins` and
115 /// `/v1/apps/marketplaces` endpoint families.
116 #[serde(default)]
117 pub plugin_management: bool,
118 /// Durable, workspace-scoped cross-task Agent Mail endpoints and events.
119 #[serde(default)]
120 pub agent_mail: bool,
121 /// `GET /v1/terminal/{name}/output` — the resumable byte stream over a
122 /// persistent Engine-owned terminal session, with absolute cursors.
123 #[serde(default)]
124 pub terminal_stream: bool,
125 /// `POST /v1/terminal/{name}/input` — bytes into the live session.
126 #[serde(default)]
127 pub terminal_input: bool,
128 /// `POST /v1/terminal/{name}/resize` — the window the child draws for.
129 #[serde(default)]
130 pub terminal_resize: bool,
131 /// `POST /v1/terminal/{name}/kill` — end the live session.
132 #[serde(default)]
133 pub terminal_kill: bool,
134 /// `GET /v1/threads/{id}/events` puts the durable `seq` on every journal
135 /// frame as the SSE `id:` and resumes from a `Last-Event-ID` header, so a
136 /// browser `EventSource` reconnects without a cursor in the query string.
137 #[serde(default)]
138 pub event_stream_resume: bool,
139 }
140
141 /// Experimental opt-in flags advertised by `GET /v1/runtime/info`.
142 ///
143 /// Fields are additive and default to `false` when omitted by older servers.
144 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
145 pub struct RuntimeExperimentalCapabilities {
146 #[serde(default)]
147 pub environments: bool,
148 }
149
150 // ---------------------------------------------------------------------------
151 // External Tool Bridge protocol types
152 // ---------------------------------------------------------------------------
153
154 /// Specification for a dynamic external tool registered by a runtime client.
155 ///
156 /// Example JSON from the spec:
157 ///
158 /// ```json
159 /// {
160 /// "namespace": "tau_bench",
161 /// "name": "get_reservation",
162 /// "description": "Look up an airline reservation.",
163 /// "input_schema": {
164 /// "type": "object",
165 /// "properties": {
166 /// "reservation_id": { "type": "string" }
167 /// },
168 /// "required": ["reservation_id"],
169 /// "additionalProperties": false
170 /// }
171 /// }
172 /// ```
173 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174 pub struct DynamicToolSpec {
175 /// Optional namespace that groups related tools (e.g. `"tau_bench"`).
176 /// When present, the runtime may expose the tool as
177 /// `<namespace>::<name>` to the model.
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub namespace: Option<String>,
180
181 /// Short tool name. Combined with `namespace` it forms a unique tool id.
182 pub name: String,
183
184 /// Human-readable description exposed to the model.
185 pub description: String,
186
187 /// JSON Schema describing the tool's input parameters.
188 pub input_schema: Value,
189
190 /// If true, the runtime may defer schema validation / tool loading until
191 /// the model actually calls the tool.
192 ///
193 /// Defaults to `false` so that older clients omitting this field still
194 /// behave the same way.
195 #[serde(default)]
196 pub defer_loading: bool,
197 }
198
199 /// Lifecycle status of a dynamic tool item shown in thread detail and event
200 /// payloads.
201 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
202 #[serde(rename_all = "snake_case")]
203 pub enum DynamicToolItemStatus {
204 InProgress,
205 Completed,
206 Failed,
207 }
208
209 /// Parameters identifying a dynamic tool call request emitted by the runtime.
210 ///
211 /// This is the typed payload for `tool_call.requested` events and also the
212 /// natural identifier used when the runtime looks up a pending call.
213 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
214 pub struct DynamicToolCallParams {
215 pub thread_id: String,
216 pub turn_id: String,
217 pub call_id: String,
218
219 /// Optional namespace that was registered with the tool.
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub namespace: Option<String>,
222
223 /// Tool name that the model invoked.
224 pub tool: String,
225
226 /// Arguments supplied by the model, validated against `input_schema`.
227 pub arguments: Value,
228 }
229
230 /// Result submitted by a runtime client after executing a dynamic tool.
231 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
232 pub struct DynamicToolCallResult {
233 /// Whether the client-side tool execution succeeded.
234 pub success: bool,
235
236 /// Content fragments returned by the tool.
237 ///
238 /// Defaults to an empty vector when omitted so clients can send a minimal
239 /// `{ "success": false }` payload.
240 #[serde(default)]
241 pub content: Vec<DynamicToolCallContent>,
242 }
243
244 /// A single content fragment inside a [`DynamicToolCallResult`].
245 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
246 #[serde(tag = "type", rename_all = "snake_case")]
247 pub enum DynamicToolCallContent {
248 InputText { text: String },
249 InputImage { image_url: String },
250 }
251
252 // ---------------------------------------------------------------------------
253 // Environment targeting protocol types
254 // ---------------------------------------------------------------------------
255
256 /// Environment target selected for a turn's shell/filesystem work.
257 ///
258 /// Example JSON:
259 ///
260 /// ```json
261 /// {
262 /// "environment_id": "local",
263 /// "cwd": "/workspace"
264 /// }
265 /// ```
266 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267 pub struct TurnEnvironmentParams {
268 pub environment_id: String,
269 pub cwd: PathBuf,
270 }
271
272 #[cfg(test)]
273 mod tests {
274 use super::*;
275 use serde_json::json;
276
277 #[test]
278 fn dynamic_tool_spec_roundtrip() {
279 let spec = DynamicToolSpec {
280 namespace: Some("tau_bench".into()),
281 name: "get_reservation".into(),
282 description: "Look up an airline reservation.".into(),
283 input_schema: json!({
284 "type": "object",
285 "properties": {
286 "reservation_id": { "type": "string" }
287 },
288 "required": ["reservation_id"],
289 "additionalProperties": false
290 }),
291 defer_loading: false,
292 };
293
294 let serialized = serde_json::to_string(&spec).unwrap();
295 let deserialized: DynamicToolSpec = serde_json::from_str(&serialized).unwrap();
296 assert_eq!(spec, deserialized);
297 }
298
299 #[test]
300 fn dynamic_tool_spec_omits_defer_loading_defaults_false() {
301 let json = r#"{
302 "namespace": "tau_bench",
303 "name": "get_reservation",
304 "description": "Look up an airline reservation.",
305 "input_schema": { "type": "object" }
306 }"#;
307
308 let spec: DynamicToolSpec = serde_json::from_str(json).unwrap();
309 assert_eq!(spec.namespace, Some("tau_bench".into()));
310 assert_eq!(spec.name, "get_reservation");
311 assert!(!spec.defer_loading);
312 }
313
314 #[test]
315 fn dynamic_tool_item_status_snake_case() {
316 assert_eq!(
317 serde_json::to_string(&DynamicToolItemStatus::InProgress).unwrap(),
318 "\"in_progress\""
319 );
320 assert_eq!(
321 serde_json::from_str::<DynamicToolItemStatus>("\"completed\"").unwrap(),
322 DynamicToolItemStatus::Completed
323 );
324 assert_eq!(
325 serde_json::from_str::<DynamicToolItemStatus>("\"failed\"").unwrap(),
326 DynamicToolItemStatus::Failed
327 );
328 }
329
330 #[test]
331 fn dynamic_tool_call_params_roundtrip() {
332 let params = DynamicToolCallParams {
333 thread_id: "thr_123".into(),
334 turn_id: "turn_456".into(),
335 call_id: "call_abc".into(),
336 namespace: Some("tau_bench".into()),
337 tool: "get_reservation".into(),
338 arguments: json!({ "reservation_id": "ABC123" }),
339 };
340
341 let serialized = serde_json::to_string(&params).unwrap();
342 let deserialized: DynamicToolCallParams = serde_json::from_str(&serialized).unwrap();
343 assert_eq!(params, deserialized);
344 }
345
346 #[test]
347 fn dynamic_tool_call_content_roundtrip() {
348 let content = vec![
349 DynamicToolCallContent::InputText {
350 text: "{\"status\":\"confirmed\"}".into(),
351 },
352 DynamicToolCallContent::InputImage {
353 image_url: "http://example.com/receipt.png".into(),
354 },
355 ];
356
357 let value = serde_json::to_value(&content).unwrap();
358 let deserialized: Vec<DynamicToolCallContent> = serde_json::from_value(value).unwrap();
359 assert_eq!(content, deserialized);
360
361 // Verify the exact JSON tag names expected by the spec.
362 assert_eq!(
363 serde_json::to_string(&DynamicToolCallContent::InputText { text: "x".into() }).unwrap(),
364 r#"{"type":"input_text","text":"x"}"#
365 );
366 assert_eq!(
367 serde_json::to_string(&DynamicToolCallContent::InputImage {
368 image_url: "y".into()
369 })
370 .unwrap(),
371 r#"{"type":"input_image","image_url":"y"}"#
372 );
373 }
374
375 #[test]
376 fn dynamic_tool_call_result_defaults_empty_content() {
377 let json = r#"{ "success": false }"#;
378 let result: DynamicToolCallResult = serde_json::from_str(json).unwrap();
379 assert!(!result.success);
380 assert!(result.content.is_empty());
381 }
382
383 #[test]
384 fn dynamic_tool_call_result_roundtrip_with_content() {
385 let result = DynamicToolCallResult {
386 success: true,
387 content: vec![DynamicToolCallContent::InputText {
388 text: "done".into(),
389 }],
390 };
391
392 let serialized = serde_json::to_string(&result).unwrap();
393 let deserialized: DynamicToolCallResult = serde_json::from_str(&serialized).unwrap();
394 assert_eq!(result, deserialized);
395 }
396
397 #[test]
398 fn turn_environment_params_roundtrip() {
399 let env = TurnEnvironmentParams {
400 environment_id: "local".into(),
401 cwd: PathBuf::from("/workspace"),
402 };
403
404 let serialized = serde_json::to_string(&env).unwrap();
405 let deserialized: TurnEnvironmentParams = serde_json::from_str(&serialized).unwrap();
406 assert_eq!(env, deserialized);
407
408 // Verify JSON from the spec deserializes directly.
409 let from_spec = r#"{
410 "environment_id": "local",
411 "cwd": "/workspace"
412 }"#;
413 let parsed: TurnEnvironmentParams = serde_json::from_str(from_spec).unwrap();
414 assert_eq!(parsed.environment_id, "local");
415 assert_eq!(parsed.cwd, PathBuf::from("/workspace"));
416 }
417
418 #[test]
419 fn runtime_capabilities_serializes_expected_shape() {
420 let caps = RuntimeCapabilities {
421 turn_output_token_limit: false,
422 account_session: true,
423 threads: true,
424 thread_shell_consent: true,
425 turns: true,
426 turn_operation_idempotency: true,
427 turn_operation_lookup: true,
428 turn_image_inputs: true,
429 turn_steer: true,
430 turn_interrupt: true,
431 event_replay: true,
432 external_tools: false,
433 environments: false,
434 worker_runtime: false,
435 fleet_run_create: true,
436 fleet_run_start: true,
437 fleet_event_replay: true,
438 fleet_event_stream: true,
439 fleet_local_target: true,
440 thread_goals: true,
441 memory: true,
442 mcp_server_management: false,
443 skill_lifecycle: false,
444 plugin_management: false,
445 agent_mail: true,
446 terminal_stream: false,
447 terminal_input: false,
448 terminal_resize: false,
449 terminal_kill: false,
450 event_stream_resume: true,
451 };
452 let value = serde_json::to_value(&caps).unwrap();
453 let obj = value.as_object().unwrap();
454 assert_eq!(obj.get("threads").unwrap(), &json!(true));
455 assert_eq!(obj.get("thread_shell_consent"), Some(&json!(true)));
456 assert!(
457 serde_json::from_value::<RuntimeCapabilities>(value.clone())
458 .unwrap()
459 .thread_shell_consent
460 );
461 let mut legacy_shell = value.clone();
462 legacy_shell
463 .as_object_mut()
464 .unwrap()
465 .remove("thread_shell_consent");
466 assert!(
467 !serde_json::from_value::<RuntimeCapabilities>(legacy_shell)
468 .unwrap()
469 .thread_shell_consent
470 );
471
472 assert_eq!(obj.get("account_session").unwrap(), &json!(true));
473 assert_eq!(obj.get("turn_operation_idempotency").unwrap(), &json!(true));
474 assert_eq!(obj.get("turn_operation_lookup").unwrap(), &json!(true));
475 let mut without_lookup = value.clone();
476 without_lookup
477 .as_object_mut()
478 .unwrap()
479 .remove("turn_operation_lookup");
480 assert!(
481 !serde_json::from_value::<RuntimeCapabilities>(without_lookup)
482 .unwrap()
483 .turn_operation_lookup
484 );
485 assert_eq!(obj.get("turn_image_inputs").unwrap(), &json!(true));
486 let mut legacy = value.clone();
487 legacy.as_object_mut().unwrap().remove("turn_image_inputs");
488 assert!(
489 !serde_json::from_value::<RuntimeCapabilities>(legacy)
490 .unwrap()
491 .turn_image_inputs
492 );
493 assert_eq!(obj.get("external_tools").unwrap(), &json!(false));
494 assert!(obj.contains_key("worker_runtime"));
495 assert_eq!(obj.get("fleet_run_create").unwrap(), &json!(true));
496 assert_eq!(obj.get("fleet_event_stream").unwrap(), &json!(true));
497 assert_eq!(obj.get("thread_goals").unwrap(), &json!(true));
498 assert_eq!(obj.get("memory").unwrap(), &json!(true));
499 assert_eq!(obj.get("plugin_management").unwrap(), &json!(false));
500 assert_eq!(obj.get("agent_mail").unwrap(), &json!(true));
501 }
502
503 #[test]
504 fn runtime_event_envelope_schema_version_default() {
505 let json = r#"{
506 "seq": 1,
507 "event": "test",
508 "kind": "test",
509 "thread_id": "thr_1",
510 "timestamp": "2026-06-12T00:00:00Z",
511 "payload": {}
512 }"#;
513 let envelope: RuntimeEventEnvelope = serde_json::from_str(json).unwrap();
514 assert_eq!(
515 envelope.schema_version,
516 RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION
517 );
518 }
519 }
520
520 lines RUST