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