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
6use runtime_types::{ConversationId, ExecutionId, OperationId, RuntimeInstanceId};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10/// Versioned OpenAPI document served by the Runtime Outer Shell. Keeping the
11/// snapshot in the contract crate makes protocol review independent of Axum.
12pub fn openapi_document() -> Value {
13    serde_json::from_str(include_str!("../openapi/runtime-v3.json"))
14        .expect("embedded Runtime OpenAPI must be valid JSON")
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18#[serde(rename_all = "camelCase", deny_unknown_fields)]
19pub struct CreateExecutionRequest {
20    pub runtime_instance_id: RuntimeInstanceId,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub conversation_id: Option<ConversationId>,
23    pub input: RuntimeInput,
24    #[serde(default)]
25    pub options: ExecutionOptions,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(
30    tag = "type",
31    rename_all = "snake_case",
32    rename_all_fields = "camelCase",
33    deny_unknown_fields
34)]
35pub enum RuntimeInput {
36    UserMessage { text: String },
37    ToolApproval { request_id: String, approved: bool },
38    ElicitationResponse { request_id: String, text: String },
39}
40
41impl RuntimeInput {
42    pub fn user_text(&self) -> Option<&str> {
43        match self {
44            Self::UserMessage { text } => Some(text),
45            _ => None,
46        }
47    }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct ExecutionOptions {
53    #[serde(default = "default_deadline_seconds")]
54    pub deadline_seconds: u64,
55    #[serde(default = "default_model_turns")]
56    pub max_model_turns: usize,
57    #[serde(default = "default_tool_calls")]
58    pub max_tool_calls: usize,
59}
60
61impl Default for ExecutionOptions {
62    fn default() -> Self {
63        Self {
64            deadline_seconds: default_deadline_seconds(),
65            max_model_turns: default_model_turns(),
66            max_tool_calls: default_tool_calls(),
67        }
68    }
69}
70
71impl ExecutionOptions {
72    pub fn validate(&self) -> Result<(), &'static str> {
73        if !(1..=3600).contains(&self.deadline_seconds) {
74            return Err("deadlineSeconds must be within 1..=3600");
75        }
76        if !(1..=256).contains(&self.max_model_turns) {
77            return Err("maxModelTurns must be within 1..=256");
78        }
79        if !(1..=2048).contains(&self.max_tool_calls) {
80            return Err("maxToolCalls must be within 1..=2048");
81        }
82        Ok(())
83    }
84}
85
86fn default_deadline_seconds() -> u64 {
87    900
88}
89fn default_model_turns() -> usize {
90    64
91}
92fn default_tool_calls() -> usize {
93    256
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97#[serde(rename_all = "camelCase", deny_unknown_fields)]
98pub struct CreateExecutionResponse {
99    pub execution: ExecutionView,
100    pub replayed: bool,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
104#[serde(rename_all = "camelCase", deny_unknown_fields)]
105pub struct ExecutionView {
106    pub id: ExecutionId,
107    pub runtime_instance_id: RuntimeInstanceId,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub conversation_id: Option<ConversationId>,
110    pub state: ExecutionState,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub outcome: Option<ExecutionOutcome>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub failure: Option<ExecutionFailure>,
115    pub created_at_ms: i64,
116    pub updated_at_ms: i64,
117}
118
119#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
120#[serde(rename_all = "snake_case")]
121pub enum ExecutionState {
122    Queued,
123    Running,
124    WaitingForInput,
125    Finalizing,
126    Completed,
127    Failed,
128    Canceled,
129}
130
131impl ExecutionState {
132    pub fn is_terminal(self) -> bool {
133        matches!(self, Self::Completed | Self::Failed | Self::Canceled)
134    }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138#[serde(rename_all = "camelCase", deny_unknown_fields)]
139pub struct ExecutionOutcome {
140    pub answer: String,
141    pub model_turns: usize,
142    pub tool_calls: usize,
143    /// `None` means the provider omitted usage; it is not equivalent to zero.
144    pub input_tokens: Option<u64>,
145    pub output_tokens: Option<u64>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149#[serde(rename_all = "camelCase", deny_unknown_fields)]
150pub struct ExecutionFailure {
151    pub code: String,
152    pub message: String,
153    pub retryable: bool,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
157#[serde(rename_all = "camelCase", deny_unknown_fields)]
158pub struct SubmitInputRequest {
159    pub operation_id: OperationId,
160    pub input: RuntimeInput,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
164#[serde(rename_all = "camelCase", deny_unknown_fields)]
165pub struct ExecutionEvent {
166    pub execution_id: ExecutionId,
167    pub sequence: u64,
168    pub created_at_ms: i64,
169    pub payload: EventPayload,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
173#[serde(tag = "type", content = "data", rename_all = "snake_case")]
174pub enum EventPayload {
175    ExecutionQueued,
176    ExecutionStarted,
177    ModelStarted {
178        turn: usize,
179    },
180    ModelCompleted {
181        turn: usize,
182    },
183    ToolStarted {
184        call_id: String,
185        name: String,
186    },
187    ToolCompleted {
188        call_id: String,
189        name: String,
190        failed: bool,
191    },
192    InteractionRequired {
193        request_id: String,
194        prompt: String,
195    },
196    InteractionReceived {
197        request_id: String,
198    },
199    Warning {
200        code: String,
201        message: String,
202    },
203    ExecutionCompleted {
204        answer: String,
205    },
206    ExecutionFailed {
207        code: String,
208        message: String,
209    },
210    ExecutionCanceled,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
214#[serde(rename_all = "camelCase", deny_unknown_fields)]
215pub struct EventPage {
216    pub items: Vec<ExecutionEvent>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub next_after: Option<u64>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
222#[serde(rename_all = "camelCase", deny_unknown_fields)]
223pub struct ApiErrorBody {
224    pub code: String,
225    pub message: String,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub request_id: Option<String>,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub details: Option<Value>,
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn input_is_strict_and_tagged() {
238        let input: RuntimeInput = serde_json::from_value(serde_json::json!({
239            "type": "user_message", "text": "hello"
240        }))
241        .unwrap();
242        assert_eq!(input.user_text(), Some("hello"));
243        assert!(
244            serde_json::from_value::<RuntimeInput>(serde_json::json!({
245                "type":"user_message", "text":"hello", "secret":"no"
246            }))
247            .is_err()
248        );
249        let approval: RuntimeInput = serde_json::from_value(serde_json::json!({
250            "type":"tool_approval", "requestId":"call-1", "approved":true
251        }))
252        .unwrap();
253        assert!(matches!(approval, RuntimeInput::ToolApproval { .. }));
254        assert!(
255            serde_json::from_value::<RuntimeInput>(serde_json::json!({
256                "type":"tool_approval", "request_id":"call-1", "approved":true
257            }))
258            .is_err()
259        );
260    }
261
262    #[test]
263    fn limits_are_bounded() {
264        let mut options = ExecutionOptions::default();
265        assert!(options.validate().is_ok());
266        options.max_tool_calls = usize::MAX;
267        assert!(options.validate().is_err());
268    }
269
270    #[test]
271    fn openapi_snapshot_covers_the_public_execution_surface() {
272        let document = openapi_document();
273        assert_eq!(document["openapi"], "3.1.0");
274        for path in [
275            "/v1/executions",
276            "/v1/executions/{executionId}",
277            "/v1/executions/{executionId}/events",
278            "/v1/executions/{executionId}/stream",
279            "/v1/executions/{executionId}/inputs",
280            "/v1/executions/{executionId}/cancel",
281        ] {
282            assert!(document["paths"].get(path).is_some(), "missing {path}");
283        }
284    }
285}