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