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