use indexmap::IndexMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::content::{AssistantContent, UserContent, UserContentField, UserTextField};
use crate::json::{JsonObject, JsonValue};
use crate::primitives::{StopReason, ThinkingLevel, TimestampMs, Usage};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolDecl {
pub name: String,
pub description: String,
#[serde(default)]
pub parameters: JsonValue,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constrained_sampling: Option<ConstrainedSampling>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ConstrainedSampling {
Disabled(FalseLiteral),
Config(ConstrainedSamplingConfig),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FalseLiteral;
impl Serialize for FalseLiteral {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_bool(false)
}
}
impl<'de> Deserialize<'de> for FalseLiteral {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
if bool::deserialize(deserializer)? {
Err(serde::de::Error::custom("expected `false`"))
} else {
Ok(Self)
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConstrainedSamplingConfig {
#[serde(rename = "type")]
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strict: Option<StrictMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variants: Option<IndexMap<String, String>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StrictMode {
Prefer,
Require,
Off,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ToolRef {
pub name: String,
}
pub type Sections = IndexMap<String, Option<String>>;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemMessage {
#[serde(default)]
pub content: UserTextField,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sections: Option<Sections>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools_added: Option<Vec<ToolDecl>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools_removed: Option<Vec<ToolRef>>,
#[serde(default)]
pub timestamp: TimestampMs,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserMessage {
#[serde(default)]
pub content: UserContentField,
#[serde(default)]
pub timestamp: TimestampMs,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssistantMessage {
#[serde(default)]
pub content: Vec<AssistantContent>,
pub api: String,
pub provider: String,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_thinking_level: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_level: Option<ThinkingLevel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diagnostics: Option<Vec<AssistantDiagnostic>>,
#[serde(default)]
pub usage: Usage,
pub stop_reason: StopReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deferred: Option<DeferredHandle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_stop_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_turn: Option<bool>,
#[serde(default)]
pub timestamp: TimestampMs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
impl AssistantMessage {
#[must_use]
pub fn empty(
api: impl Into<String>,
provider: impl Into<String>,
model: impl Into<String>,
timestamp: TimestampMs,
) -> Self {
Self {
content: Vec::new(),
api: api.into(),
provider: provider.into(),
model: model.into(),
response_model: None,
response_id: None,
provider_thinking_level: None,
thinking_level: None,
diagnostics: None,
usage: Usage::default(),
stop_reason: StopReason::Pending,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp,
duration_ms: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssistantDiagnostic {
#[serde(rename = "type")]
pub kind: String,
pub timestamp: TimestampMs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<DiagnosticError>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<JsonObject>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticError {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stack: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<JsonValue>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeferredHandle {
pub provider: String,
pub model_id: String,
pub api: String,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub poll_after_ms: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::json::keep_null"
)]
pub data: Option<JsonValue>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NestedStatus {
Ok,
Error,
Unfinished,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NestedToolCallRecord {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arguments: Option<JsonObject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arguments_bytes: Option<u64>,
pub status: NestedStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct NestedToolCalls {
pub calls: Vec<NestedToolCallRecord>,
pub complete: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolResultMessage {
pub tool_call_id: String,
pub tool_name: String,
#[serde(default)]
pub content: Vec<UserContent>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::json::keep_null"
)]
pub details: Option<JsonValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nested_calls: Option<NestedToolCalls>,
pub is_error: bool,
#[serde(default)]
pub timestamp: TimestampMs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
#[allow(
clippy::large_enum_variant,
reason = "variants mirror the wire shapes; boxing would leak into every match across crates"
)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "role")]
pub enum Message {
#[serde(rename = "system")]
System(SystemMessage),
#[serde(rename = "user")]
User(UserMessage),
#[serde(rename = "assistant")]
Assistant(AssistantMessage),
#[serde(rename = "toolResult")]
ToolResult(ToolResultMessage),
}
impl Message {
#[must_use]
pub const fn timestamp(&self) -> TimestampMs {
match self {
Self::System(m) => m.timestamp,
Self::User(m) => m.timestamp,
Self::Assistant(m) => m.timestamp,
Self::ToolResult(m) => m.timestamp,
}
}
#[must_use]
pub const fn role(&self) -> &'static str {
match self {
Self::System(_) => "system",
Self::User(_) => "user",
Self::Assistant(_) => "assistant",
Self::ToolResult(_) => "toolResult",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BashExecutionMessage {
pub command: String,
#[serde(default)]
pub output: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(default)]
pub cancelled: bool,
#[serde(default)]
pub truncated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub full_output_path: Option<String>,
#[serde(default)]
pub timestamp: TimestampMs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclude_from_context: Option<bool>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomMessage {
pub custom_type: String,
#[serde(default)]
pub content: UserContentField,
#[serde(default)]
pub display: bool,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::json::keep_null"
)]
pub details: Option<JsonValue>,
#[serde(default)]
pub timestamp: TimestampMs,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchSummaryMessage {
pub summary: String,
#[serde(default)]
pub from_id: Option<String>,
#[serde(default)]
pub timestamp: TimestampMs,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompactionSummaryMessage {
pub summary: String,
#[serde(default)]
pub tokens_before: u64,
#[serde(default)]
pub timestamp: TimestampMs,
}
#[allow(
clippy::large_enum_variant,
reason = "variants mirror the wire shapes; boxing would leak into every match across crates"
)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "role")]
pub enum AgentMessage {
#[serde(rename = "system")]
System(SystemMessage),
#[serde(rename = "user")]
User(UserMessage),
#[serde(rename = "assistant")]
Assistant(AssistantMessage),
#[serde(rename = "toolResult")]
ToolResult(ToolResultMessage),
#[serde(rename = "bashExecution")]
BashExecution(BashExecutionMessage),
#[serde(rename = "custom")]
Custom(CustomMessage),
#[serde(rename = "branchSummary")]
BranchSummary(BranchSummaryMessage),
#[serde(rename = "compactionSummary")]
CompactionSummary(CompactionSummaryMessage),
}
impl AgentMessage {
#[must_use]
pub fn as_llm(&self) -> Option<Message> {
match self {
Self::System(m) => Some(Message::System(m.clone())),
Self::User(m) => Some(Message::User(m.clone())),
Self::Assistant(m) => Some(Message::Assistant(m.clone())),
Self::ToolResult(m) => Some(Message::ToolResult(m.clone())),
_ => None,
}
}
#[must_use]
pub const fn role(&self) -> &'static str {
match self {
Self::System(_) => "system",
Self::User(_) => "user",
Self::Assistant(_) => "assistant",
Self::ToolResult(_) => "toolResult",
Self::BashExecution(_) => "bashExecution",
Self::Custom(_) => "custom",
Self::BranchSummary(_) => "branchSummary",
Self::CompactionSummary(_) => "compactionSummary",
}
}
#[must_use]
pub const fn timestamp(&self) -> TimestampMs {
match self {
Self::System(m) => m.timestamp,
Self::User(m) => m.timestamp,
Self::Assistant(m) => m.timestamp,
Self::ToolResult(m) => m.timestamp,
Self::BashExecution(m) => m.timestamp,
Self::Custom(m) => m.timestamp,
Self::BranchSummary(m) => m.timestamp,
Self::CompactionSummary(m) => m.timestamp,
}
}
#[must_use]
pub const fn as_system(&self) -> Option<&SystemMessage> {
match self {
Self::System(m) => Some(m),
_ => None,
}
}
}
impl From<Message> for AgentMessage {
fn from(message: Message) -> Self {
match message {
Message::System(m) => Self::System(m),
Message::User(m) => Self::User(m),
Message::Assistant(m) => Self::Assistant(m),
Message::ToolResult(m) => Self::ToolResult(m),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn round_trip(raw: &JsonValue) -> Option<JsonValue> {
let message: AgentMessage = serde_json::from_value(raw.clone()).ok()?;
serde_json::to_value(message).ok()
}
#[test]
fn doc_examples_round_trip() {
for raw in [
json!({"role":"system","content":"","sections":{"preamble":"You are…","tools":"<tools>","cwd":"/p"},
"toolsAdded":[{"name":"read","description":"...","parameters":{}}],"timestamp":1_733_234_400_000_i64}),
json!({"role":"system","content":"","sections":{"skills":"<skills>","gone":null},"toolsRemoved":[{"name":"write"}],"timestamp":1}),
json!({"role":"user","content":"Hello","timestamp":1}),
json!({"role":"assistant","content":[{"type":"text","text":"Hi!"}],"api":"anthropic-messages","provider":"anthropic",
"model":"claude-sonnet-4-5","usage":{"input":1,"output":2,"cacheRead":0,"cacheWrite":0,"totalTokens":3,
"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":2}),
json!({"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false,"timestamp":3}),
json!({"role":"bashExecution","command":"ls","output":"","exitCode":0,"cancelled":false,"truncated":false,"timestamp":4}),
json!({"role":"custom","customType":"x","content":"c","display":true,"details":{"a":1},"timestamp":5}),
json!({"role":"branchSummary","summary":"s","fromId":null,"timestamp":6}),
json!({"role":"compactionSummary","summary":"s","tokensBefore":10,"timestamp":7}),
] {
assert_eq!(round_trip(&raw), Some(raw));
}
}
#[test]
fn constrained_sampling_false_and_config() {
let decl: ToolDecl = serde_json::from_value(
json!({"name":"a","description":"b","parameters":{},"constrainedSampling":false}),
)
.unwrap_or_else(|_| ToolDecl {
name: String::new(),
description: String::new(),
parameters: JsonValue::Null,
constrained_sampling: None,
});
assert_eq!(
decl.constrained_sampling,
Some(ConstrainedSampling::Disabled(FalseLiteral))
);
let raw = json!({"type":"json_schema","strict":"prefer"});
let config: Option<ConstrainedSampling> = serde_json::from_value(raw.clone()).ok();
assert!(matches!(config, Some(ConstrainedSampling::Config(_))));
assert_eq!(serde_json::to_value(config).ok(), Some(raw));
assert!(serde_json::from_value::<FalseLiteral>(json!(true)).is_err());
}
}