1mod claude;
7mod openai;
8mod task;
9
10pub use claude::*;
11pub use openai::*;
12pub use task::*;
13
14use std::collections::BTreeMap;
15
16use runtime_types::{ConversationId, ExecutionId, OperationId, RuntimeInstanceId, WorkspaceId};
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20pub fn openapi_document() -> Value {
23 serde_json::from_str(include_str!("../openapi/runtime-v3.json"))
24 .expect("embedded Runtime OpenAPI must be valid JSON")
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub struct CreateExecutionRequest {
30 pub runtime_instance_id: RuntimeInstanceId,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub conversation_id: Option<ConversationId>,
33 pub input: RuntimeInput,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub workspace_id: Option<WorkspaceId>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub model: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub instructions: Option<String>,
45 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
47 pub metadata: BTreeMap<String, String>,
48 #[serde(default)]
52 pub generation: ModelGenerationOptions,
53 #[serde(default)]
54 pub options: ExecutionOptions,
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
58#[serde(rename_all = "camelCase", deny_unknown_fields)]
59pub struct ModelGenerationOptions {
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub max_output_tokens: Option<u32>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub temperature: Option<f32>,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub stop_sequences: Vec<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub response_format: Option<Value>,
68 #[serde(default)]
69 pub tool_choice: ModelToolChoice,
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
74pub enum ModelToolChoice {
75 #[default]
76 Auto,
77 None,
78}
79
80impl ModelGenerationOptions {
81 pub fn validate(&self) -> Result<(), &'static str> {
82 if self.max_output_tokens.is_some_and(|value| value == 0) {
83 return Err("maxOutputTokens must be greater than zero");
84 }
85 if self
86 .temperature
87 .is_some_and(|value| !value.is_finite() || !(0.0..=2.0).contains(&value))
88 {
89 return Err("temperature must be finite and within 0..=2");
90 }
91 if self.stop_sequences.len() > 4
92 || self
93 .stop_sequences
94 .iter()
95 .any(|value| value.is_empty() || value.len() > 1024)
96 {
97 return Err("stopSequences supports at most 4 non-empty values up to 1024 bytes");
98 }
99 if self
100 .response_format
101 .as_ref()
102 .is_some_and(|value| !value.is_object())
103 {
104 return Err("responseFormat must be a JSON object");
105 }
106 Ok(())
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111#[serde(
112 tag = "type",
113 rename_all = "snake_case",
114 rename_all_fields = "camelCase",
115 deny_unknown_fields
116)]
117pub enum RuntimeInput {
118 UserMessage { text: String },
119 Messages { messages: Vec<RuntimeMessage> },
120 ToolApproval { request_id: String, approved: bool },
121 ElicitationResponse { request_id: String, text: String },
122}
123
124impl RuntimeInput {
125 pub fn user_text(&self) -> Option<&str> {
126 match self {
127 Self::UserMessage { text } => Some(text),
128 Self::Messages { messages } => messages
129 .iter()
130 .rev()
131 .find(|message| message.role == RuntimeMessageRole::User)
132 .or_else(|| messages.last())
133 .map(|message| message.text.as_str()),
134 _ => None,
135 }
136 }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140#[serde(rename_all = "camelCase", deny_unknown_fields)]
141pub struct RuntimeMessage {
142 pub role: RuntimeMessageRole,
143 pub text: String,
144}
145
146#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(rename_all = "snake_case")]
148pub enum RuntimeMessageRole {
149 System,
150 Developer,
151 User,
152 Assistant,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156#[serde(rename_all = "camelCase", deny_unknown_fields)]
157pub struct ExecutionOptions {
158 #[serde(default = "default_deadline_seconds")]
159 pub deadline_seconds: u64,
160 #[serde(default = "default_model_turns")]
161 pub max_model_turns: usize,
162 #[serde(default = "default_tool_calls")]
163 pub max_tool_calls: usize,
164}
165
166impl Default for ExecutionOptions {
167 fn default() -> Self {
168 Self {
169 deadline_seconds: default_deadline_seconds(),
170 max_model_turns: default_model_turns(),
171 max_tool_calls: default_tool_calls(),
172 }
173 }
174}
175
176impl ExecutionOptions {
177 pub fn validate(&self) -> Result<(), &'static str> {
178 if !(1..=604_800).contains(&self.deadline_seconds) {
181 return Err("deadlineSeconds must be within 1..=604800");
182 }
183 if !(1..=256).contains(&self.max_model_turns) {
184 return Err("maxModelTurns must be within 1..=256");
185 }
186 if !(1..=2048).contains(&self.max_tool_calls) {
187 return Err("maxToolCalls must be within 1..=2048");
188 }
189 Ok(())
190 }
191}
192
193fn default_deadline_seconds() -> u64 {
194 900
195}
196fn default_model_turns() -> usize {
197 64
198}
199fn default_tool_calls() -> usize {
200 256
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
204#[serde(rename_all = "camelCase", deny_unknown_fields)]
205pub struct CreateExecutionResponse {
206 pub execution: ExecutionView,
207 pub replayed: bool,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct ExecutionView {
213 pub id: ExecutionId,
214 pub runtime_instance_id: RuntimeInstanceId,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub conversation_id: Option<ConversationId>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub workspace_id: Option<WorkspaceId>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub model: Option<String>,
221 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
222 pub metadata: BTreeMap<String, String>,
223 pub state: ExecutionState,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub outcome: Option<ExecutionOutcome>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub failure: Option<ExecutionFailure>,
228 pub created_at_ms: i64,
229 pub updated_at_ms: i64,
230}
231
232#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
233#[serde(rename_all = "snake_case")]
234pub enum ExecutionState {
235 Queued,
236 Running,
237 WaitingForInput,
238 Finalizing,
239 Completed,
240 Failed,
241 Canceled,
242}
243
244impl ExecutionState {
245 pub fn is_terminal(self) -> bool {
246 matches!(self, Self::Completed | Self::Failed | Self::Canceled)
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
251#[serde(rename_all = "camelCase", deny_unknown_fields)]
252pub struct ExecutionOutcome {
253 pub answer: String,
254 pub model_turns: usize,
255 pub tool_calls: usize,
256 pub input_tokens: Option<u64>,
258 pub output_tokens: Option<u64>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
262#[serde(rename_all = "camelCase", deny_unknown_fields)]
263pub struct ExecutionFailure {
264 pub code: String,
265 pub message: String,
266 pub retryable: bool,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
270#[serde(rename_all = "camelCase", deny_unknown_fields)]
271pub struct SubmitInputRequest {
272 pub operation_id: OperationId,
273 pub input: RuntimeInput,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
277#[serde(rename_all = "camelCase", deny_unknown_fields)]
278pub struct ExecutionEvent {
279 pub execution_id: ExecutionId,
280 pub sequence: u64,
281 pub created_at_ms: i64,
282 pub payload: EventPayload,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
286#[serde(tag = "type", content = "data", rename_all = "snake_case")]
287pub enum EventPayload {
288 ExecutionQueued,
289 ExecutionStarted,
290 ModelStarted {
291 turn: usize,
292 invocation_id: String,
293 },
294 ModelCompleted {
295 turn: usize,
296 invocation_id: String,
297 finish_reason: String,
298 input_tokens: Option<u64>,
299 output_tokens: Option<u64>,
300 },
301 ToolStarted {
302 call_id: String,
303 name: String,
304 },
305 ToolCompleted {
306 call_id: String,
307 name: String,
308 failed: bool,
309 },
310 InteractionRequired {
311 request_id: String,
312 prompt: String,
313 },
314 InteractionReceived {
315 request_id: String,
316 },
317 Warning {
318 code: String,
319 message: String,
320 },
321 ExecutionCompleted {
322 answer: String,
323 },
324 ExecutionFailed {
325 code: String,
326 message: String,
327 },
328 ExecutionCanceled,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
332#[serde(rename_all = "camelCase", deny_unknown_fields)]
333pub struct EventPage {
334 pub items: Vec<ExecutionEvent>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub next_after: Option<u64>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
340#[serde(rename_all = "camelCase", deny_unknown_fields)]
341pub struct ApiErrorBody {
342 pub code: String,
343 pub message: String,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub request_id: Option<String>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub details: Option<Value>,
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn input_is_strict_and_tagged() {
356 let input: RuntimeInput = serde_json::from_value(serde_json::json!({
357 "type": "user_message", "text": "hello"
358 }))
359 .unwrap();
360 assert_eq!(input.user_text(), Some("hello"));
361 assert!(
362 serde_json::from_value::<RuntimeInput>(serde_json::json!({
363 "type":"user_message", "text":"hello", "secret":"no"
364 }))
365 .is_err()
366 );
367 let approval: RuntimeInput = serde_json::from_value(serde_json::json!({
368 "type":"tool_approval", "requestId":"call-1", "approved":true
369 }))
370 .unwrap();
371 assert!(matches!(approval, RuntimeInput::ToolApproval { .. }));
372 assert!(
373 serde_json::from_value::<RuntimeInput>(serde_json::json!({
374 "type":"tool_approval", "request_id":"call-1", "approved":true
375 }))
376 .is_err()
377 );
378 }
379
380 #[test]
381 fn limits_are_bounded() {
382 let mut options = ExecutionOptions::default();
383 assert!(options.validate().is_ok());
384 options.max_tool_calls = usize::MAX;
385 assert!(options.validate().is_err());
386 }
387
388 #[test]
389 fn openapi_snapshot_covers_the_public_execution_surface() {
390 let document = openapi_document();
391 assert_eq!(document["openapi"], "3.1.0");
392 for path in [
393 "/v1/agent/tasks",
394 "/v1/agent/tasks/{taskId}/stream",
395 "/v1/agent/tasks/{taskId}/ws",
396 "/v1/responses",
397 "/v1/messages",
398 ] {
399 assert!(document["paths"].get(path).is_some(), "missing {path}");
400 }
401 }
402
403 #[test]
404 fn native_task_preserves_workspace_model_and_long_task_limits() {
405 let request: CreateAgentTaskRequest = serde_json::from_value(serde_json::json!({
406 "runtimeId": "runtime-1",
407 "input": {"text": "inspect it"},
408 "workspace": {"id": "workspace-2"},
409 "model": {"id": "model-2", "maxOutputTokens": 4096},
410 "limits": {"deadlineSeconds": 86400, "maxModelTurns": 8, "maxToolCalls": 32},
411 "metadata": {"traceId": "trace-1"}
412 }))
413 .unwrap();
414 let execution = request.into_execution();
415 assert_eq!(execution.workspace_id.unwrap().as_str(), "workspace-2");
416 assert_eq!(execution.model.as_deref(), Some("model-2"));
417 assert_eq!(execution.generation.max_output_tokens, Some(4096));
418 assert_eq!(execution.options.deadline_seconds, 86400);
419 assert!(execution.options.validate().is_ok());
420 }
421
422 #[test]
423 fn provider_contracts_accept_their_native_parameter_names() {
424 let openai: OpenAiResponseRequest = serde_json::from_value(serde_json::json!({
425 "model": "runtime-1",
426 "input": "hello",
427 "max_output_tokens": 512,
428 "background": true,
429 "agent": {"workspaceId": "workspace-2"}
430 }))
431 .unwrap();
432 assert!(openai.background);
433 assert_eq!(openai.max_output_tokens, Some(512));
434
435 let claude: ClaudeMessageRequest = serde_json::from_value(serde_json::json!({
436 "model": "runtime-1",
437 "max_tokens": 512,
438 "messages": [{"role": "user", "content": "hello"}],
439 "agent": {"workspaceId": "workspace-2"}
440 }))
441 .unwrap();
442 assert_eq!(claude.max_tokens, 512);
443 assert_eq!(claude.messages[0].role, "user");
444 }
445}