astrid-types 0.3.0

Shared data types for the Astrid secure agent runtime — IPC payloads, LLM schemas, and kernel API types
Documentation
//! Cross-boundary IPC message schemas and payloads.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;

/// A cross-boundary message sent over the event bus between WASM guests and the host.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IpcMessage {
    /// Topic pattern or exact match (e.g., `astrid.cli.input`).
    pub topic: String,
    /// Standardized payload structure.
    pub payload: IpcPayload,
    /// Optional cryptographic signature for stateless verification across a distributed swarm.
    pub signature: Option<Vec<u8>>,
    /// Identifier of the sender plugin or agent.
    pub source_id: Uuid,
    /// Timestamp when the message was dispatched.
    pub timestamp: DateTime<Utc>,
}

impl IpcMessage {
    /// Create a new IPC message.
    #[must_use]
    pub fn new(topic: impl Into<String>, payload: IpcPayload, source_id: Uuid) -> Self {
        Self {
            topic: topic.into(),
            payload,
            signature: None,
            source_id,
            timestamp: Utc::now(),
        }
    }

    /// Attach a signature for swarm verification.
    #[must_use]
    pub fn with_signature(mut self, signature: Vec<u8>) -> Self {
        self.signature = Some(signature);
        self
    }
}

/// Default session ID for conversations.
fn default_session_id() -> String {
    "default".into()
}

/// Standardized cross-boundary payload schemas.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum IpcPayload {
    /// Raw, arbitrary JSON.
    RawJson(Value),
    /// User input provided via a frontend (CLI, Telegram).
    UserInput {
        /// The raw text input.
        text: String,
        /// Session ID for conversation continuity. Defaults to `"default"`.
        #[serde(default = "default_session_id")]
        session_id: String,
        /// Optional extra context.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        context: Option<Value>,
    },
    /// A response generated by an agent.
    AgentResponse {
        /// The text output.
        text: String,
        /// True if this is the final response in a chain.
        is_final: bool,
        /// Session ID for multi-session attribution.
        #[serde(default = "default_session_id")]
        session_id: String,
    },
    /// An interceptor or capsule request for capability approval.
    ApprovalRequired {
        /// Opaque correlation ID.
        request_id: String,
        /// The action being requested (e.g. "git push").
        action: String,
        /// The resource target (e.g. full command string).
        resource: String,
        /// Justification.
        reason: String,
        /// Risk classification: "low", "medium", "high", or "critical".
        risk_level: String,
    },
    /// Response to an [`ApprovalRequired`](IpcPayload::ApprovalRequired).
    ApprovalResponse {
        /// Must match the `request_id` from the originating request.
        request_id: String,
        /// The user's decision.
        decision: String,
        /// Optional reason for the decision.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        reason: Option<String>,
    },
    /// A capsule needs environment variables to be provided by the user.
    OnboardingRequired {
        /// The ID of the capsule requiring onboarding.
        capsule_id: String,
        /// Rich field descriptors for each missing env var.
        fields: Vec<OnboardingField>,
    },
    /// Request an LLM provider capsule to generate a response.
    LlmRequest {
        /// The unique ID of the request, used for routing the response stream back.
        request_id: Uuid,
        /// The requested model name (e.g. "claude-3-5-sonnet").
        model: String,
        /// The conversation history.
        messages: Vec<crate::llm::Message>,
        /// The tools available to the model.
        tools: Vec<crate::llm::LlmToolDefinition>,
        /// The system prompt.
        system: String,
    },
    /// A stream event from an LLM provider capsule.
    LlmStreamEvent {
        /// The unique ID of the request this stream belongs to.
        request_id: Uuid,
        /// The actual stream event (`TokenDelta`, `ToolCallStart`, etc).
        event: crate::llm::StreamEvent,
    },
    /// The final, non-streaming LLM response.
    LlmResponse {
        /// The unique ID of the request this response belongs to.
        request_id: Uuid,
        /// The final response object.
        response: crate::llm::LlmResponse,
    },
    /// Request the Tool Router capsule to execute a tool.
    ToolExecuteRequest {
        /// The unique ID of the tool call.
        call_id: String,
        /// The name of the tool to execute.
        tool_name: String,
        /// The JSON arguments.
        arguments: Value,
    },
    /// The result of a tool execution.
    ToolExecuteResult {
        /// The unique ID of the tool call.
        call_id: String,
        /// The result of the execution.
        result: crate::llm::ToolCallResult,
    },
    /// Request cancellation of in-flight tool executions.
    ToolCancelRequest {
        /// The call IDs of the tool invocations to cancel.
        call_ids: Vec<String>,
    },
    /// A capsule is requesting the user to select from a list of options.
    SelectionRequired {
        /// Opaque ID so the capsule can correlate the response.
        request_id: String,
        /// Title/prompt shown above the list.
        title: String,
        /// The selectable options.
        options: Vec<SelectionOption>,
        /// IPC topic to publish the user's choice back on.
        callback_topic: String,
    },
    /// A lifecycle hook is requesting user input via the `elicit` API.
    ElicitRequest {
        /// Correlation ID.
        request_id: Uuid,
        /// The capsule requesting input.
        capsule_id: String,
        /// Field descriptor reusing the onboarding schema.
        field: OnboardingField,
    },
    /// Response to an [`ElicitRequest`](IpcPayload::ElicitRequest).
    ElicitResponse {
        /// Must match the `request_id` from the originating request.
        request_id: Uuid,
        /// The user's input. `None` if the user cancelled.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        value: Option<String>,
        /// For `Array`-type fields, the collected items.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        values: Option<Vec<String>>,
    },
    /// A client has connected.
    Connect,
    /// A client is disconnecting gracefully.
    Disconnect {
        /// Optional reason for disconnection (e.g. "quit", "timeout").
        #[serde(default, skip_serializing_if = "Option::is_none")]
        reason: Option<String>,
    },
    /// Arbitrary JSON data for unstructured plugins.
    Custom {
        /// Raw data.
        data: Value,
    },
    /// Unrecognized payload type from a newer protocol version.
    #[serde(other)]
    Unknown,
}

impl IpcPayload {
    /// Returns `true` if `tag` matches a known serde variant name.
    #[must_use]
    pub fn is_known_tag(tag: &str) -> bool {
        matches!(
            tag,
            "raw_json"
                | "user_input"
                | "agent_response"
                | "approval_required"
                | "approval_response"
                | "onboarding_required"
                | "llm_request"
                | "llm_stream_event"
                | "llm_response"
                | "tool_execute_request"
                | "tool_execute_result"
                | "tool_cancel_request"
                | "selection_required"
                | "elicit_request"
                | "elicit_response"
                | "connect"
                | "disconnect"
                | "custom"
        )
    }

    /// Deserialize a JSON [`Value`] into an `IpcPayload`, falling back to
    /// [`Custom`](Self::Custom) for unrecognised or missing type tags.
    pub fn from_json_value(data: Value) -> Self {
        let is_known = data
            .get("type")
            .and_then(|v| v.as_str())
            .is_some_and(Self::is_known_tag);

        if is_known {
            serde_json::from_value::<Self>(data.clone()).unwrap_or(Self::Custom { data })
        } else {
            Self::Custom { data }
        }
    }

    /// Serialize only the guest-facing payload data.
    ///
    /// [`Custom`](Self::Custom) and [`RawJson`](Self::RawJson) payloads return
    /// the inner data value directly (no `type` wrapper). Structured variants
    /// return the full tagged serialization.
    ///
    /// # Errors
    ///
    /// Returns `serde_json::Error` if serialization fails.
    pub fn to_guest_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
        match self {
            Self::Custom { data } | Self::RawJson(data) => serde_json::to_vec(data),
            other => serde_json::to_vec(other),
        }
    }
}

/// A single option in a `SelectionRequired` picker.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SelectionOption {
    /// Machine-readable identifier sent back to the capsule.
    pub id: String,
    /// Human-readable label shown in the picker.
    pub label: String,
    /// Optional description shown alongside the label.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
}

/// A field descriptor for capsule onboarding.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OnboardingField {
    /// The environment variable key.
    pub key: String,
    /// The prompt shown to the user.
    pub prompt: String,
    /// Optional description for additional context.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// The input type for this field.
    pub field_type: OnboardingFieldType,
    /// Optional default value.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default: Option<String>,
    /// Placeholder hint text shown when the input is empty (e.g. `"sk-..."`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub placeholder: Option<String>,
}

/// The type of input expected for an onboarding field.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum OnboardingFieldType {
    /// Free-form text input.
    Text,
    /// Masked secret input.
    Secret,
    /// Selection from a fixed set of choices.
    Enum(Vec<String>),
    /// Multi-value array input (user adds items one at a time).
    Array,
}

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

    #[test]
    fn ipc_message_signature() {
        let msg = IpcMessage::new(
            "test.topic",
            IpcPayload::AgentResponse {
                text: "hello".into(),
                is_final: true,
                session_id: "default".into(),
            },
            Uuid::new_v4(),
        );
        assert!(msg.signature.is_none());

        let signed = msg.with_signature(vec![1, 2, 3]);
        assert_eq!(signed.signature, Some(vec![1, 2, 3]));
    }

    #[test]
    fn unknown_type_tag_deserializes_to_unknown() {
        let json = r#"{"type":"future_variant","some_data":42}"#;
        let payload: IpcPayload = serde_json::from_str(json).unwrap();
        assert_eq!(payload, IpcPayload::Unknown);
    }

    #[test]
    fn onboarding_field_roundtrip() {
        let field = OnboardingField {
            key: "apiKey".into(),
            prompt: "Enter API key".into(),
            description: None,
            field_type: OnboardingFieldType::Secret,
            default: None,
            placeholder: None,
        };
        let json = serde_json::to_string(&field).unwrap();
        let parsed: OnboardingField = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed, field);
    }

    #[test]
    fn to_guest_bytes_custom_returns_inner_data() {
        let data = serde_json::json!({"session_id": "abc", "messages": []});
        let payload = IpcPayload::Custom { data: data.clone() };
        let bytes = payload.to_guest_bytes().unwrap();
        let roundtrip: Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(roundtrip, data);
        assert!(roundtrip.get("type").is_none());
    }
}