Skip to main content

codewhale_protocol/runtime/
mod.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7pub const RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION: u32 = 1;
8pub const RUNTIME_API_VERSION: &str = "1.0";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct RuntimeEventEnvelope {
12    #[serde(default = "default_runtime_event_envelope_schema_version")]
13    pub schema_version: u32,
14    pub seq: u64,
15    pub event: String,
16    pub kind: String,
17    pub thread_id: String,
18    pub turn_id: Option<String>,
19    pub item_id: Option<String>,
20    pub timestamp: String,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub created_at: Option<String>,
23    pub payload: Value,
24    #[serde(default)]
25    #[serde(flatten)]
26    pub extra: BTreeMap<String, Value>,
27}
28
29fn default_runtime_event_envelope_schema_version() -> u32 {
30    RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION
31}
32
33// ---------------------------------------------------------------------------
34// Capability advertisement
35// ---------------------------------------------------------------------------
36
37/// Fixed capability map advertised by `GET /v1/runtime/info`.
38///
39/// All fields are required on serialization so clients can rely on the shape.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct RuntimeCapabilities {
42    #[serde(default)]
43    pub account_session: bool,
44    pub threads: bool,
45    pub turns: bool,
46    pub turn_steer: bool,
47    pub turn_interrupt: bool,
48    pub event_replay: bool,
49    pub external_tools: bool,
50    pub environments: bool,
51    pub worker_runtime: bool,
52    #[serde(default)]
53    pub fleet_run_create: bool,
54    #[serde(default)]
55    pub fleet_run_start: bool,
56    #[serde(default)]
57    pub fleet_event_replay: bool,
58    #[serde(default)]
59    pub fleet_event_stream: bool,
60    #[serde(default)]
61    pub fleet_local_target: bool,
62}
63
64/// Experimental opt-in flags advertised by `GET /v1/runtime/info`.
65///
66/// Fields are additive and default to `false` when omitted by older servers.
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct RuntimeExperimentalCapabilities {
69    #[serde(default)]
70    pub environments: bool,
71}
72
73// ---------------------------------------------------------------------------
74// External Tool Bridge protocol types
75// ---------------------------------------------------------------------------
76
77/// Specification for a dynamic external tool registered by a runtime client.
78///
79/// Example JSON from the spec:
80///
81/// ```json
82/// {
83///   "namespace": "tau_bench",
84///   "name": "get_reservation",
85///   "description": "Look up an airline reservation.",
86///   "input_schema": {
87///     "type": "object",
88///     "properties": {
89///       "reservation_id": { "type": "string" }
90///     },
91///     "required": ["reservation_id"],
92///     "additionalProperties": false
93///   }
94/// }
95/// ```
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97pub struct DynamicToolSpec {
98    /// Optional namespace that groups related tools (e.g. `"tau_bench"`).
99    /// When present, the runtime may expose the tool as
100    /// `<namespace>::<name>` to the model.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub namespace: Option<String>,
103
104    /// Short tool name. Combined with `namespace` it forms a unique tool id.
105    pub name: String,
106
107    /// Human-readable description exposed to the model.
108    pub description: String,
109
110    /// JSON Schema describing the tool's input parameters.
111    pub input_schema: Value,
112
113    /// If true, the runtime may defer schema validation / tool loading until
114    /// the model actually calls the tool.
115    ///
116    /// Defaults to `false` so that older clients omitting this field still
117    /// behave the same way.
118    #[serde(default)]
119    pub defer_loading: bool,
120}
121
122/// Lifecycle status of a dynamic tool item shown in thread detail and event
123/// payloads.
124#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
125#[serde(rename_all = "snake_case")]
126pub enum DynamicToolItemStatus {
127    InProgress,
128    Completed,
129    Failed,
130}
131
132/// Parameters identifying a dynamic tool call request emitted by the runtime.
133///
134/// This is the typed payload for `tool_call.requested` events and also the
135/// natural identifier used when the runtime looks up a pending call.
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137pub struct DynamicToolCallParams {
138    pub thread_id: String,
139    pub turn_id: String,
140    pub call_id: String,
141
142    /// Optional namespace that was registered with the tool.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub namespace: Option<String>,
145
146    /// Tool name that the model invoked.
147    pub tool: String,
148
149    /// Arguments supplied by the model, validated against `input_schema`.
150    pub arguments: Value,
151}
152
153/// Result submitted by a runtime client after executing a dynamic tool.
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
155pub struct DynamicToolCallResult {
156    /// Whether the client-side tool execution succeeded.
157    pub success: bool,
158
159    /// Content fragments returned by the tool.
160    ///
161    /// Defaults to an empty vector when omitted so clients can send a minimal
162    /// `{ "success": false }` payload.
163    #[serde(default)]
164    pub content: Vec<DynamicToolCallContent>,
165}
166
167/// A single content fragment inside a [`DynamicToolCallResult`].
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169#[serde(tag = "type", rename_all = "snake_case")]
170pub enum DynamicToolCallContent {
171    InputText { text: String },
172    InputImage { image_url: String },
173}
174
175// ---------------------------------------------------------------------------
176// Environment targeting protocol types
177// ---------------------------------------------------------------------------
178
179/// Environment target selected for a turn's shell/filesystem work.
180///
181/// Example JSON:
182///
183/// ```json
184/// {
185///   "environment_id": "local",
186///   "cwd": "/workspace"
187/// }
188/// ```
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct TurnEnvironmentParams {
191    pub environment_id: String,
192    pub cwd: PathBuf,
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use serde_json::json;
199
200    #[test]
201    fn dynamic_tool_spec_roundtrip() {
202        let spec = DynamicToolSpec {
203            namespace: Some("tau_bench".into()),
204            name: "get_reservation".into(),
205            description: "Look up an airline reservation.".into(),
206            input_schema: json!({
207                "type": "object",
208                "properties": {
209                    "reservation_id": { "type": "string" }
210                },
211                "required": ["reservation_id"],
212                "additionalProperties": false
213            }),
214            defer_loading: false,
215        };
216
217        let serialized = serde_json::to_string(&spec).unwrap();
218        let deserialized: DynamicToolSpec = serde_json::from_str(&serialized).unwrap();
219        assert_eq!(spec, deserialized);
220    }
221
222    #[test]
223    fn dynamic_tool_spec_omits_defer_loading_defaults_false() {
224        let json = r#"{
225            "namespace": "tau_bench",
226            "name": "get_reservation",
227            "description": "Look up an airline reservation.",
228            "input_schema": { "type": "object" }
229        }"#;
230
231        let spec: DynamicToolSpec = serde_json::from_str(json).unwrap();
232        assert_eq!(spec.namespace, Some("tau_bench".into()));
233        assert_eq!(spec.name, "get_reservation");
234        assert!(!spec.defer_loading);
235    }
236
237    #[test]
238    fn dynamic_tool_item_status_snake_case() {
239        assert_eq!(
240            serde_json::to_string(&DynamicToolItemStatus::InProgress).unwrap(),
241            "\"in_progress\""
242        );
243        assert_eq!(
244            serde_json::from_str::<DynamicToolItemStatus>("\"completed\"").unwrap(),
245            DynamicToolItemStatus::Completed
246        );
247        assert_eq!(
248            serde_json::from_str::<DynamicToolItemStatus>("\"failed\"").unwrap(),
249            DynamicToolItemStatus::Failed
250        );
251    }
252
253    #[test]
254    fn dynamic_tool_call_params_roundtrip() {
255        let params = DynamicToolCallParams {
256            thread_id: "thr_123".into(),
257            turn_id: "turn_456".into(),
258            call_id: "call_abc".into(),
259            namespace: Some("tau_bench".into()),
260            tool: "get_reservation".into(),
261            arguments: json!({ "reservation_id": "ABC123" }),
262        };
263
264        let serialized = serde_json::to_string(&params).unwrap();
265        let deserialized: DynamicToolCallParams = serde_json::from_str(&serialized).unwrap();
266        assert_eq!(params, deserialized);
267    }
268
269    #[test]
270    fn dynamic_tool_call_content_roundtrip() {
271        let content = vec![
272            DynamicToolCallContent::InputText {
273                text: "{\"status\":\"confirmed\"}".into(),
274            },
275            DynamicToolCallContent::InputImage {
276                image_url: "http://example.com/receipt.png".into(),
277            },
278        ];
279
280        let value = serde_json::to_value(&content).unwrap();
281        let deserialized: Vec<DynamicToolCallContent> = serde_json::from_value(value).unwrap();
282        assert_eq!(content, deserialized);
283
284        // Verify the exact JSON tag names expected by the spec.
285        assert_eq!(
286            serde_json::to_string(&DynamicToolCallContent::InputText { text: "x".into() }).unwrap(),
287            r#"{"type":"input_text","text":"x"}"#
288        );
289        assert_eq!(
290            serde_json::to_string(&DynamicToolCallContent::InputImage {
291                image_url: "y".into()
292            })
293            .unwrap(),
294            r#"{"type":"input_image","image_url":"y"}"#
295        );
296    }
297
298    #[test]
299    fn dynamic_tool_call_result_defaults_empty_content() {
300        let json = r#"{ "success": false }"#;
301        let result: DynamicToolCallResult = serde_json::from_str(json).unwrap();
302        assert!(!result.success);
303        assert!(result.content.is_empty());
304    }
305
306    #[test]
307    fn dynamic_tool_call_result_roundtrip_with_content() {
308        let result = DynamicToolCallResult {
309            success: true,
310            content: vec![DynamicToolCallContent::InputText {
311                text: "done".into(),
312            }],
313        };
314
315        let serialized = serde_json::to_string(&result).unwrap();
316        let deserialized: DynamicToolCallResult = serde_json::from_str(&serialized).unwrap();
317        assert_eq!(result, deserialized);
318    }
319
320    #[test]
321    fn turn_environment_params_roundtrip() {
322        let env = TurnEnvironmentParams {
323            environment_id: "local".into(),
324            cwd: PathBuf::from("/workspace"),
325        };
326
327        let serialized = serde_json::to_string(&env).unwrap();
328        let deserialized: TurnEnvironmentParams = serde_json::from_str(&serialized).unwrap();
329        assert_eq!(env, deserialized);
330
331        // Verify JSON from the spec deserializes directly.
332        let from_spec = r#"{
333            "environment_id": "local",
334            "cwd": "/workspace"
335        }"#;
336        let parsed: TurnEnvironmentParams = serde_json::from_str(from_spec).unwrap();
337        assert_eq!(parsed.environment_id, "local");
338        assert_eq!(parsed.cwd, PathBuf::from("/workspace"));
339    }
340
341    #[test]
342    fn runtime_capabilities_serializes_expected_shape() {
343        let caps = RuntimeCapabilities {
344            account_session: true,
345            threads: true,
346            turns: true,
347            turn_steer: true,
348            turn_interrupt: true,
349            event_replay: true,
350            external_tools: false,
351            environments: false,
352            worker_runtime: false,
353            fleet_run_create: true,
354            fleet_run_start: true,
355            fleet_event_replay: true,
356            fleet_event_stream: true,
357            fleet_local_target: true,
358        };
359        let value = serde_json::to_value(&caps).unwrap();
360        let obj = value.as_object().unwrap();
361        assert_eq!(obj.get("threads").unwrap(), &json!(true));
362        assert_eq!(obj.get("account_session").unwrap(), &json!(true));
363        assert_eq!(obj.get("external_tools").unwrap(), &json!(false));
364        assert!(obj.contains_key("worker_runtime"));
365        assert_eq!(obj.get("fleet_run_create").unwrap(), &json!(true));
366        assert_eq!(obj.get("fleet_event_stream").unwrap(), &json!(true));
367    }
368
369    #[test]
370    fn runtime_event_envelope_schema_version_default() {
371        let json = r#"{
372            "seq": 1,
373            "event": "test",
374            "kind": "test",
375            "thread_id": "thr_1",
376            "timestamp": "2026-06-12T00:00:00Z",
377            "payload": {}
378        }"#;
379        let envelope: RuntimeEventEnvelope = serde_json::from_str(json).unwrap();
380        assert_eq!(
381            envelope.schema_version,
382            RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION
383        );
384    }
385}