返回 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 }
122
123 /// Experimental opt-in flags advertised by `GET /v1/runtime/info`.
124 ///
125 /// Fields are additive and default to `false` when omitted by older servers.
126 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
127 pub struct RuntimeExperimentalCapabilities {
128 #[serde(default)]
129 pub environments: bool,
130 }
131
132 // ---------------------------------------------------------------------------
133 // External Tool Bridge protocol types
134 // ---------------------------------------------------------------------------
135
136 /// Specification for a dynamic external tool registered by a runtime client.
137 ///
138 /// Example JSON from the spec:
139 ///
140 /// ```json
141 /// {
142 /// "namespace": "tau_bench",
143 /// "name": "get_reservation",
144 /// "description": "Look up an airline reservation.",
145 /// "input_schema": {
146 /// "type": "object",
147 /// "properties": {
148 /// "reservation_id": { "type": "string" }
149 /// },
150 /// "required": ["reservation_id"],
151 /// "additionalProperties": false
152 /// }
153 /// }
154 /// ```
155 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
156 pub struct DynamicToolSpec {
157 /// Optional namespace that groups related tools (e.g. `"tau_bench"`).
158 /// When present, the runtime may expose the tool as
159 /// `<namespace>::<name>` to the model.
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub namespace: Option<String>,
162
163 /// Short tool name. Combined with `namespace` it forms a unique tool id.
164 pub name: String,
165
166 /// Human-readable description exposed to the model.
167 pub description: String,
168
169 /// JSON Schema describing the tool's input parameters.
170 pub input_schema: Value,
171
172 /// If true, the runtime may defer schema validation / tool loading until
173 /// the model actually calls the tool.
174 ///
175 /// Defaults to `false` so that older clients omitting this field still
176 /// behave the same way.
177 #[serde(default)]
178 pub defer_loading: bool,
179 }
180
181 /// Lifecycle status of a dynamic tool item shown in thread detail and event
182 /// payloads.
183 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
184 #[serde(rename_all = "snake_case")]
185 pub enum DynamicToolItemStatus {
186 InProgress,
187 Completed,
188 Failed,
189 }
190
191 /// Parameters identifying a dynamic tool call request emitted by the runtime.
192 ///
193 /// This is the typed payload for `tool_call.requested` events and also the
194 /// natural identifier used when the runtime looks up a pending call.
195 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
196 pub struct DynamicToolCallParams {
197 pub thread_id: String,
198 pub turn_id: String,
199 pub call_id: String,
200
201 /// Optional namespace that was registered with the tool.
202 #[serde(skip_serializing_if = "Option::is_none")]
203 pub namespace: Option<String>,
204
205 /// Tool name that the model invoked.
206 pub tool: String,
207
208 /// Arguments supplied by the model, validated against `input_schema`.
209 pub arguments: Value,
210 }
211
212 /// Result submitted by a runtime client after executing a dynamic tool.
213 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
214 pub struct DynamicToolCallResult {
215 /// Whether the client-side tool execution succeeded.
216 pub success: bool,
217
218 /// Content fragments returned by the tool.
219 ///
220 /// Defaults to an empty vector when omitted so clients can send a minimal
221 /// `{ "success": false }` payload.
222 #[serde(default)]
223 pub content: Vec<DynamicToolCallContent>,
224 }
225
226 /// A single content fragment inside a [`DynamicToolCallResult`].
227 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
228 #[serde(tag = "type", rename_all = "snake_case")]
229 pub enum DynamicToolCallContent {
230 InputText { text: String },
231 InputImage { image_url: String },
232 }
233
234 // ---------------------------------------------------------------------------
235 // Environment targeting protocol types
236 // ---------------------------------------------------------------------------
237
238 /// Environment target selected for a turn's shell/filesystem work.
239 ///
240 /// Example JSON:
241 ///
242 /// ```json
243 /// {
244 /// "environment_id": "local",
245 /// "cwd": "/workspace"
246 /// }
247 /// ```
248 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
249 pub struct TurnEnvironmentParams {
250 pub environment_id: String,
251 pub cwd: PathBuf,
252 }
253
254 #[cfg(test)]
255 mod tests {
256 use super::*;
257 use serde_json::json;
258
259 #[test]
260 fn dynamic_tool_spec_roundtrip() {
261 let spec = DynamicToolSpec {
262 namespace: Some("tau_bench".into()),
263 name: "get_reservation".into(),
264 description: "Look up an airline reservation.".into(),
265 input_schema: json!({
266 "type": "object",
267 "properties": {
268 "reservation_id": { "type": "string" }
269 },
270 "required": ["reservation_id"],
271 "additionalProperties": false
272 }),
273 defer_loading: false,
274 };
275
276 let serialized = serde_json::to_string(&spec).unwrap();
277 let deserialized: DynamicToolSpec = serde_json::from_str(&serialized).unwrap();
278 assert_eq!(spec, deserialized);
279 }
280
281 #[test]
282 fn dynamic_tool_spec_omits_defer_loading_defaults_false() {
283 let json = r#"{
284 "namespace": "tau_bench",
285 "name": "get_reservation",
286 "description": "Look up an airline reservation.",
287 "input_schema": { "type": "object" }
288 }"#;
289
290 let spec: DynamicToolSpec = serde_json::from_str(json).unwrap();
291 assert_eq!(spec.namespace, Some("tau_bench".into()));
292 assert_eq!(spec.name, "get_reservation");
293 assert!(!spec.defer_loading);
294 }
295
296 #[test]
297 fn dynamic_tool_item_status_snake_case() {
298 assert_eq!(
299 serde_json::to_string(&DynamicToolItemStatus::InProgress).unwrap(),
300 "\"in_progress\""
301 );
302 assert_eq!(
303 serde_json::from_str::<DynamicToolItemStatus>("\"completed\"").unwrap(),
304 DynamicToolItemStatus::Completed
305 );
306 assert_eq!(
307 serde_json::from_str::<DynamicToolItemStatus>("\"failed\"").unwrap(),
308 DynamicToolItemStatus::Failed
309 );
310 }
311
312 #[test]
313 fn dynamic_tool_call_params_roundtrip() {
314 let params = DynamicToolCallParams {
315 thread_id: "thr_123".into(),
316 turn_id: "turn_456".into(),
317 call_id: "call_abc".into(),
318 namespace: Some("tau_bench".into()),
319 tool: "get_reservation".into(),
320 arguments: json!({ "reservation_id": "ABC123" }),
321 };
322
323 let serialized = serde_json::to_string(&params).unwrap();
324 let deserialized: DynamicToolCallParams = serde_json::from_str(&serialized).unwrap();
325 assert_eq!(params, deserialized);
326 }
327
328 #[test]
329 fn dynamic_tool_call_content_roundtrip() {
330 let content = vec![
331 DynamicToolCallContent::InputText {
332 text: "{\"status\":\"confirmed\"}".into(),
333 },
334 DynamicToolCallContent::InputImage {
335 image_url: "http://example.com/receipt.png".into(),
336 },
337 ];
338
339 let value = serde_json::to_value(&content).unwrap();
340 let deserialized: Vec<DynamicToolCallContent> = serde_json::from_value(value).unwrap();
341 assert_eq!(content, deserialized);
342
343 // Verify the exact JSON tag names expected by the spec.
344 assert_eq!(
345 serde_json::to_string(&DynamicToolCallContent::InputText { text: "x".into() }).unwrap(),
346 r#"{"type":"input_text","text":"x"}"#
347 );
348 assert_eq!(
349 serde_json::to_string(&DynamicToolCallContent::InputImage {
350 image_url: "y".into()
351 })
352 .unwrap(),
353 r#"{"type":"input_image","image_url":"y"}"#
354 );
355 }
356
357 #[test]
358 fn dynamic_tool_call_result_defaults_empty_content() {
359 let json = r#"{ "success": false }"#;
360 let result: DynamicToolCallResult = serde_json::from_str(json).unwrap();
361 assert!(!result.success);
362 assert!(result.content.is_empty());
363 }
364
365 #[test]
366 fn dynamic_tool_call_result_roundtrip_with_content() {
367 let result = DynamicToolCallResult {
368 success: true,
369 content: vec![DynamicToolCallContent::InputText {
370 text: "done".into(),
371 }],
372 };
373
374 let serialized = serde_json::to_string(&result).unwrap();
375 let deserialized: DynamicToolCallResult = serde_json::from_str(&serialized).unwrap();
376 assert_eq!(result, deserialized);
377 }
378
379 #[test]
380 fn turn_environment_params_roundtrip() {
381 let env = TurnEnvironmentParams {
382 environment_id: "local".into(),
383 cwd: PathBuf::from("/workspace"),
384 };
385
386 let serialized = serde_json::to_string(&env).unwrap();
387 let deserialized: TurnEnvironmentParams = serde_json::from_str(&serialized).unwrap();
388 assert_eq!(env, deserialized);
389
390 // Verify JSON from the spec deserializes directly.
391 let from_spec = r#"{
392 "environment_id": "local",
393 "cwd": "/workspace"
394 }"#;
395 let parsed: TurnEnvironmentParams = serde_json::from_str(from_spec).unwrap();
396 assert_eq!(parsed.environment_id, "local");
397 assert_eq!(parsed.cwd, PathBuf::from("/workspace"));
398 }
399
400 #[test]
401 fn runtime_capabilities_serializes_expected_shape() {
402 let caps = RuntimeCapabilities {
403 turn_output_token_limit: false,
404 account_session: true,
405 threads: true,
406 thread_shell_consent: true,
407 turns: true,
408 turn_operation_idempotency: true,
409 turn_operation_lookup: true,
410 turn_image_inputs: true,
411 turn_steer: true,
412 turn_interrupt: true,
413 event_replay: true,
414 external_tools: false,
415 environments: false,
416 worker_runtime: false,
417 fleet_run_create: true,
418 fleet_run_start: true,
419 fleet_event_replay: true,
420 fleet_event_stream: true,
421 fleet_local_target: true,
422 thread_goals: true,
423 memory: true,
424 mcp_server_management: false,
425 skill_lifecycle: false,
426 plugin_management: false,
427 agent_mail: true,
428 };
429 let value = serde_json::to_value(&caps).unwrap();
430 let obj = value.as_object().unwrap();
431 assert_eq!(obj.get("threads").unwrap(), &json!(true));
432 assert_eq!(obj.get("thread_shell_consent"), Some(&json!(true)));
433 assert!(
434 serde_json::from_value::<RuntimeCapabilities>(value.clone())
435 .unwrap()
436 .thread_shell_consent
437 );
438 let mut legacy_shell = value.clone();
439 legacy_shell
440 .as_object_mut()
441 .unwrap()
442 .remove("thread_shell_consent");
443 assert!(
444 !serde_json::from_value::<RuntimeCapabilities>(legacy_shell)
445 .unwrap()
446 .thread_shell_consent
447 );
448
449 assert_eq!(obj.get("account_session").unwrap(), &json!(true));
450 assert_eq!(obj.get("turn_operation_idempotency").unwrap(), &json!(true));
451 assert_eq!(obj.get("turn_operation_lookup").unwrap(), &json!(true));
452 let mut without_lookup = value.clone();
453 without_lookup
454 .as_object_mut()
455 .unwrap()
456 .remove("turn_operation_lookup");
457 assert!(
458 !serde_json::from_value::<RuntimeCapabilities>(without_lookup)
459 .unwrap()
460 .turn_operation_lookup
461 );
462 assert_eq!(obj.get("turn_image_inputs").unwrap(), &json!(true));
463 let mut legacy = value.clone();
464 legacy.as_object_mut().unwrap().remove("turn_image_inputs");
465 assert!(
466 !serde_json::from_value::<RuntimeCapabilities>(legacy)
467 .unwrap()
468 .turn_image_inputs
469 );
470 assert_eq!(obj.get("external_tools").unwrap(), &json!(false));
471 assert!(obj.contains_key("worker_runtime"));
472 assert_eq!(obj.get("fleet_run_create").unwrap(), &json!(true));
473 assert_eq!(obj.get("fleet_event_stream").unwrap(), &json!(true));
474 assert_eq!(obj.get("thread_goals").unwrap(), &json!(true));
475 assert_eq!(obj.get("memory").unwrap(), &json!(true));
476 assert_eq!(obj.get("plugin_management").unwrap(), &json!(false));
477 assert_eq!(obj.get("agent_mail").unwrap(), &json!(true));
478 }
479
480 #[test]
481 fn runtime_event_envelope_schema_version_default() {
482 let json = r#"{
483 "seq": 1,
484 "event": "test",
485 "kind": "test",
486 "thread_id": "thr_1",
487 "timestamp": "2026-06-12T00:00:00Z",
488 "payload": {}
489 }"#;
490 let envelope: RuntimeEventEnvelope = serde_json::from_str(json).unwrap();
491 assert_eq!(
492 envelope.schema_version,
493 RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION
494 );
495 }
496 }
497
497 lines RUST