agent-runtime-http-api 0.1.0

Internal HTTP API contract for Agent Runtime
Documentation
//! Public Open API contract for Runtime executions.
//!
//! This crate contains transport-neutral DTOs only. Axum, persistence, Agent
//! Infra, and execution behavior are deliberately excluded.

use runtime_types::{ConversationId, ExecutionId, OperationId, RuntimeInstanceId};
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Versioned OpenAPI document served by the Runtime Outer Shell. Keeping the
/// snapshot in the contract crate makes protocol review independent of Axum.
pub fn openapi_document() -> Value {
    serde_json::from_str(include_str!("../openapi/runtime-v3.json"))
        .expect("embedded Runtime OpenAPI must be valid JSON")
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateExecutionRequest {
    pub runtime_instance_id: RuntimeInstanceId,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub conversation_id: Option<ConversationId>,
    pub input: RuntimeInput,
    #[serde(default)]
    pub options: ExecutionOptions,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(
    tag = "type",
    rename_all = "snake_case",
    rename_all_fields = "camelCase",
    deny_unknown_fields
)]
pub enum RuntimeInput {
    UserMessage { text: String },
    ToolApproval { request_id: String, approved: bool },
    ElicitationResponse { request_id: String, text: String },
}

impl RuntimeInput {
    pub fn user_text(&self) -> Option<&str> {
        match self {
            Self::UserMessage { text } => Some(text),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExecutionOptions {
    #[serde(default = "default_deadline_seconds")]
    pub deadline_seconds: u64,
    #[serde(default = "default_model_turns")]
    pub max_model_turns: usize,
    #[serde(default = "default_tool_calls")]
    pub max_tool_calls: usize,
}

impl Default for ExecutionOptions {
    fn default() -> Self {
        Self {
            deadline_seconds: default_deadline_seconds(),
            max_model_turns: default_model_turns(),
            max_tool_calls: default_tool_calls(),
        }
    }
}

impl ExecutionOptions {
    pub fn validate(&self) -> Result<(), &'static str> {
        if !(1..=3600).contains(&self.deadline_seconds) {
            return Err("deadlineSeconds must be within 1..=3600");
        }
        if !(1..=256).contains(&self.max_model_turns) {
            return Err("maxModelTurns must be within 1..=256");
        }
        if !(1..=2048).contains(&self.max_tool_calls) {
            return Err("maxToolCalls must be within 1..=2048");
        }
        Ok(())
    }
}

fn default_deadline_seconds() -> u64 {
    900
}
fn default_model_turns() -> usize {
    64
}
fn default_tool_calls() -> usize {
    256
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateExecutionResponse {
    pub execution: ExecutionView,
    pub replayed: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExecutionView {
    pub id: ExecutionId,
    pub runtime_instance_id: RuntimeInstanceId,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub conversation_id: Option<ConversationId>,
    pub state: ExecutionState,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub outcome: Option<ExecutionOutcome>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub failure: Option<ExecutionFailure>,
    pub created_at_ms: i64,
    pub updated_at_ms: i64,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionState {
    Queued,
    Running,
    WaitingForInput,
    Finalizing,
    Completed,
    Failed,
    Canceled,
}

impl ExecutionState {
    pub fn is_terminal(self) -> bool {
        matches!(self, Self::Completed | Self::Failed | Self::Canceled)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExecutionOutcome {
    pub answer: String,
    pub model_turns: usize,
    pub tool_calls: usize,
    /// `None` means the provider omitted usage; it is not equivalent to zero.
    pub input_tokens: Option<u64>,
    pub output_tokens: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExecutionFailure {
    pub code: String,
    pub message: String,
    pub retryable: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SubmitInputRequest {
    pub operation_id: OperationId,
    pub input: RuntimeInput,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExecutionEvent {
    pub execution_id: ExecutionId,
    pub sequence: u64,
    pub created_at_ms: i64,
    pub payload: EventPayload,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum EventPayload {
    ExecutionQueued,
    ExecutionStarted,
    ModelStarted {
        turn: usize,
    },
    ModelCompleted {
        turn: usize,
    },
    ToolStarted {
        call_id: String,
        name: String,
    },
    ToolCompleted {
        call_id: String,
        name: String,
        failed: bool,
    },
    InteractionRequired {
        request_id: String,
        prompt: String,
    },
    InteractionReceived {
        request_id: String,
    },
    Warning {
        code: String,
        message: String,
    },
    ExecutionCompleted {
        answer: String,
    },
    ExecutionFailed {
        code: String,
        message: String,
    },
    ExecutionCanceled,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EventPage {
    pub items: Vec<ExecutionEvent>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub next_after: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ApiErrorBody {
    pub code: String,
    pub message: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub request_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub details: Option<Value>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn input_is_strict_and_tagged() {
        let input: RuntimeInput = serde_json::from_value(serde_json::json!({
            "type": "user_message", "text": "hello"
        }))
        .unwrap();
        assert_eq!(input.user_text(), Some("hello"));
        assert!(
            serde_json::from_value::<RuntimeInput>(serde_json::json!({
                "type":"user_message", "text":"hello", "secret":"no"
            }))
            .is_err()
        );
        let approval: RuntimeInput = serde_json::from_value(serde_json::json!({
            "type":"tool_approval", "requestId":"call-1", "approved":true
        }))
        .unwrap();
        assert!(matches!(approval, RuntimeInput::ToolApproval { .. }));
        assert!(
            serde_json::from_value::<RuntimeInput>(serde_json::json!({
                "type":"tool_approval", "request_id":"call-1", "approved":true
            }))
            .is_err()
        );
    }

    #[test]
    fn limits_are_bounded() {
        let mut options = ExecutionOptions::default();
        assert!(options.validate().is_ok());
        options.max_tool_calls = usize::MAX;
        assert!(options.validate().is_err());
    }

    #[test]
    fn openapi_snapshot_covers_the_public_execution_surface() {
        let document = openapi_document();
        assert_eq!(document["openapi"], "3.1.0");
        for path in [
            "/v1/executions",
            "/v1/executions/{executionId}",
            "/v1/executions/{executionId}/events",
            "/v1/executions/{executionId}/stream",
            "/v1/executions/{executionId}/inputs",
            "/v1/executions/{executionId}/cancel",
        ] {
            assert!(document["paths"].get(path).is_some(), "missing {path}");
        }
    }
}