use futures::stream::BoxStream; use serde::{Deserialize, Serialize}; use serde_json::Value; /// Provider-neutral conversation role. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ChatRole { User, Assistant, } /// Provider-neutral message content. Tool results travel as user-role parts, /// matching both wire formats' conventions. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentPart { // Struct variant (not newtype): internally-tagged enums cannot // serialize newtype primitives, and this type round-trips through // `agent_runs.checkpoint`. Text { text: String, }, ToolUse { id: String, name: String, input: Value, }, ToolResult { tool_use_id: String, content: Value, }, } /// Anthropic and OpenAI both restrict tool names to /// `^[a-zA-Z0-9_-]{1,128}$`; our registry uses dotted names /// (`clock.now`, `email.send`). The HTTP providers encode dots as `__` /// on the wire and decode on receipt — internal naming (DB step rows, /// scenarios, UI traces) never changes. No registry tool may contain /// a literal `__`. pub fn wire_tool_name(internal: &str) -> String { internal.replace('.', "__") } pub fn internal_tool_name(wire: &str) -> String { wire.replace("__", ".") } impl ContentPart { /// Convenience constructor for plain text parts. pub fn text(value: impl Into) -> ContentPart { ContentPart::Text { text: value.into() } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ChatMessage { pub role: ChatRole, pub parts: Vec, } /// A tool offered to the model. `input_schema` is JSON Schema. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ToolDescriptor { pub name: String, pub description: String, pub input_schema: Value, } /// The checkpointable, provider-neutral request. This struct is what gets /// serialized into `agent_runs.checkpoint`, so resume works identically /// across providers. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ChatRequest { pub system: String, pub messages: Vec, pub tools: Vec, pub model: String, pub max_tokens: u32, /// When true, providers that support a server-side web-search tool (Anthropic) /// attach it so the model can ground answers in live web data. #[serde(default)] pub web_search: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StopReason { EndTurn, ToolUse, MaxTokens, } /// Streaming events every provider normalizes to. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum LlmEvent { TextDelta(String), ToolUse { id: String, name: String, input: Value, }, /// Token accounting for this provider call (drives credit metering). Usage { input_tokens: u32, output_tokens: u32, }, Stop(StopReason), } #[derive(Debug, thiserror::Error)] pub enum LlmError { #[error("scenario error: {0}")] Scenario(String), #[error("transport error: {0}")] Transport(String), #[error("provider returned an error: {0}")] Api(String), #[error("malformed provider response: {0}")] Wire(String), } pub type EventStream = BoxStream<'static, Result>; #[async_trait::async_trait] pub trait LlmProvider: Send + Sync { async fn stream(&self, request: ChatRequest) -> Result; } #[cfg(test)] mod wire_name_tests { use super::*; #[test] fn dotted_registry_names_round_trip_the_wire_codec() { for name in [ "clock.now", "email.send", "files.write", "files.list", "files.delete", "routine.schedule", "chat.send", "chat.inbox", "browser.goto", "shell.exec", "slack.post", ] { let wire = wire_tool_name(name); assert!( wire.chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'), "{wire} must satisfy the Anthropic/OpenAI name pattern" ); assert_eq!(internal_tool_name(&wire), name, "lossless round trip"); } } }