Skip to main content

agent_runtime_http_api/
lib.rs

1//! Public Open API contract for Runtime executions.
2//!
3//! This crate contains transport-neutral DTOs only. Axum, persistence, Agent
4//! Infra, and execution behavior are deliberately excluded.
5
6mod 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
20/// Versioned OpenAPI document served by the Runtime Outer Shell. Keeping the
21/// snapshot in the contract crate makes protocol review independent of Axum.
22pub 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    /// Selects the workspace for this execution. When omitted, the Runtime
35    /// binding's default workspace is used. Agent Infra delegation remains the
36    /// authorization boundary; this field only selects a scoped resource.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub workspace_id: Option<WorkspaceId>,
39    /// Optional downstream model override for the native Task API.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub model: Option<String>,
42    /// Request-scoped instructions augment the immutable Agent Definition.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub instructions: Option<String>,
45    /// Bounded string metadata is safe to correlate through traces.
46    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
47    pub metadata: BTreeMap<String, String>,
48    /// Provider-neutral model parameters shared by the native Task API and
49    /// the OpenAI/Claude wire adapters. Unsupported provider-only fields are
50    /// rejected at ingress instead of being silently discarded.
51    #[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        // Long-running Tasks remain bounded. Crash recovery still depends on
179        // the host selecting a durable ExecutionJournal implementation.
180        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    /// `None` means the provider omitted usage; it is not equivalent to zero.
257    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}