use crate::capabilities::LlmCapabilities;
use crate::completion::{CompletionRequest, CompletionResponse, StopReason, Usage};
use crate::context::NamespaceError;
use crate::ids::{RunId, SpanId};
use crate::task::Task;
use crate::MetadataMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::OffsetDateTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SchemaVersion {
pub major: u16,
pub minor: u16,
}
impl SchemaVersion {
pub const CURRENT: SchemaVersion = SchemaVersion { major: 1, minor: 0 };
}
impl std::fmt::Display for SchemaVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}", self.major, self.minor)
}
}
impl Serialize for SchemaVersion {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for SchemaVersion {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
let (maj, min) = s
.split_once('.')
.ok_or_else(|| serde::de::Error::custom("schema version must be `MAJOR.MINOR`"))?;
let major = maj.parse().map_err(serde::de::Error::custom)?;
let minor = min.parse().map_err(serde::de::Error::custom)?;
Ok(SchemaVersion { major, minor })
}
}
#[cfg(feature = "schemars-export")]
impl schemars::JsonSchema for SchemaVersion {
fn schema_name() -> String {
"SchemaVersion".into()
}
fn json_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::schema::SchemaObject {
instance_type: Some(schemars::schema::InstanceType::String.into()),
string: Some(Box::new(schemars::schema::StringValidation {
pattern: Some(r"^\d+\.\d+$".into()),
..Default::default()
})),
..Default::default()
}
.into()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct Event {
pub v: SchemaVersion,
pub seq: u64,
pub run_id: RunId,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "time::serde::rfc3339::option"
)]
#[cfg_attr(feature = "schemars-export", schemars(with = "Option<String>"))]
pub timestamp: Option<OffsetDateTime>,
pub span_id: SpanId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<u64>,
#[serde(flatten)]
pub kind: EventKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub redactions: Vec<String>,
}
impl Event {
pub fn new(seq: u64, run_id: RunId, span_id: impl Into<SpanId>, kind: EventKind) -> Self {
Self {
v: SchemaVersion::CURRENT,
seq,
run_id,
timestamp: Some(OffsetDateTime::now_utc()),
span_id: span_id.into(),
parent: None,
kind,
redactions: Vec::new(),
}
}
pub fn with_parent(mut self, parent: u64) -> Self {
self.parent = Some(parent);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
#[non_exhaustive]
pub enum EventKind {
#[serde(rename = "meta")]
Meta(MetaPayload),
#[serde(rename = "run.started")]
RunStarted(RunStartedPayload),
#[serde(rename = "run.finished")]
RunFinished(RunFinishedPayload),
#[serde(rename = "turn.started")]
TurnStarted(TurnPayload),
#[serde(rename = "turn.finished")]
TurnFinished(TurnFinishedPayload),
#[serde(rename = "turn.revised")]
TurnRevised(TurnRevisedPayload),
#[serde(rename = "llm.request")]
LlmRequest(LlmRequestPayload),
#[serde(rename = "llm.response")]
LlmResponse(LlmResponsePayload),
#[serde(rename = "llm.stream.chunk")]
LlmStreamChunk(Value),
#[serde(rename = "llm.retry")]
LlmRetry(LlmRetryPayload),
#[serde(rename = "llm.failed")]
LlmFailed(LlmFailedPayload),
#[serde(rename = "tool.call.started")]
ToolCallStarted(ToolCallStartedPayload),
#[serde(rename = "tool.call.finished")]
ToolCallFinished(ToolCallFinishedPayload),
#[serde(rename = "tool.call.failed")]
ToolCallFailed(ToolCallFailedPayload),
#[serde(rename = "tool.approval.requested")]
ToolApprovalRequested(Value),
#[serde(rename = "tool.approval.decided")]
ToolApprovalDecided(Value),
#[serde(rename = "memory.evicted")]
MemoryEvicted(Value),
#[serde(rename = "memory.summarized")]
MemorySummarized(Value),
#[serde(rename = "memory.retrieved")]
MemoryRetrieved(Value),
#[serde(rename = "budget.exceeded")]
BudgetExceeded(Value),
#[serde(rename = "policy.input.checked")]
PolicyInputChecked(Value),
#[serde(rename = "policy.output.checked")]
PolicyOutputChecked(Value),
#[serde(rename = "policy.blocked")]
PolicyBlocked(Value),
#[serde(rename = "planner.proposed")]
PlannerProposed(Value),
#[serde(rename = "planner.revised")]
PlannerRevised(Value),
#[serde(rename = "planner.committed")]
PlannerCommitted(Value),
#[serde(rename = "critic.assessed")]
CriticAssessed(Value),
#[serde(rename = "critic.rejected")]
CriticRejected(Value),
#[serde(rename = "critic.revised")]
CriticRevised(Value),
#[serde(rename = "critic.failed")]
CriticFailed(Value),
#[serde(rename = "reflection.generated")]
ReflectionGenerated(Value),
#[serde(rename = "reflection.injected")]
ReflectionInjected(Value),
#[serde(rename = "human.interrupt")]
HumanInterrupt(Value),
#[serde(rename = "human.inject")]
HumanInject(Value),
#[serde(rename = "user.simulated.message")]
UserSimulatedMessage(Value),
#[serde(rename = "user.simulated.ended")]
UserSimulatedEnded(Value),
#[serde(rename = "user.log")]
UserLog(UserLogPayload),
#[serde(other)]
Unknown,
}
pub const RESERVED_NAMESPACE_PREFIXES: &[&str] = &[
"run.",
"turn.",
"llm.",
"tool.",
"memory.",
"budget.",
"policy.",
"planner.",
"critic.",
"reflection.",
"human.",
"user.simulated.",
"meta.",
];
impl EventKind {
pub fn user_log(namespace: impl Into<String>, data: Value) -> Result<Self, NamespaceError> {
let namespace = namespace.into();
if namespace.is_empty() {
return Err(NamespaceError::Empty);
}
for reserved in RESERVED_NAMESPACE_PREFIXES {
let bare = reserved.trim_end_matches('.');
if namespace == bare || namespace.starts_with(reserved) {
return Err(NamespaceError::BuiltinCollision(namespace));
}
}
Ok(EventKind::UserLog(UserLogPayload { namespace, data }))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct MetaPayload {
pub schema_version: SchemaVersion,
pub harness_version: String,
pub task_snapshot: Task,
pub llm_capabilities: LlmCapabilities,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct RunStartedPayload {
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
pub extra: MetadataMap,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct RunFinishedPayload {
pub termination: String,
pub turns: u32,
pub tool_calls: u32,
#[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
pub extra: MetadataMap,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct TurnPayload {
pub turn_index: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct TurnFinishedPayload {
pub turn_index: u32,
pub stop_reason: StopReason,
pub usage: Usage,
pub tool_calls: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct TurnRevisedPayload {
pub original_seq: u64,
pub replacement_seq: u64,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct LlmRequestPayload {
pub request: CompletionRequest,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct LlmResponsePayload {
pub response: CompletionResponse,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct LlmRetryPayload {
pub attempt: u32,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct LlmFailedPayload {
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct ToolCallStartedPayload {
pub tool_name: String,
pub tool_use_id: String,
pub input: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct ToolCallFinishedPayload {
pub tool_name: String,
pub tool_use_id: String,
pub output: Value,
#[serde(default)]
pub truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct ToolCallFailedPayload {
pub tool_name: String,
pub tool_use_id: String,
pub reason: String,
#[serde(default)]
pub recoverable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
pub struct UserLogPayload {
pub namespace: String,
#[serde(flatten)]
pub data: Value,
}
pub type EventPayload = Value;
pub type EventConstructionError = NamespaceError;
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::ModelId;
use crate::message::{Content, Message};
use serde_json::json;
fn sample_request() -> CompletionRequest {
CompletionRequest {
messages: vec![Message::Assistant {
content: vec![
Content::text("Let me look around."),
Content::ToolUse {
id: "tu_1".into(),
name: "fs_list".into(),
input: json!({"path": "."}),
},
],
stop_reason: Some(StopReason::ToolUse),
meta: MetadataMap::new(),
}],
tools: Vec::new(),
system: None,
max_tokens: Some(1024),
temperature: None,
stop_sequences: Vec::new(),
cache_hints: Default::default(),
extensions: MetadataMap::new(),
}
}
#[test]
fn llm_request_event_round_trips_through_jsonl() {
let req = sample_request();
let event = Event::new(
42,
RunId::new(),
SpanId::from("span_1"),
EventKind::LlmRequest(LlmRequestPayload {
request: req,
provider: Some("anthropic".into()),
}),
);
let bytes = serde_json::to_vec(&event).expect("serialize");
let back: Event = serde_json::from_slice(&bytes).expect("deserialize");
assert_eq!(back.seq, 42);
match back.kind {
EventKind::LlmRequest(p) => {
assert_eq!(p.provider.as_deref(), Some("anthropic"));
assert_eq!(p.request.messages.len(), 1);
}
other => panic!("expected LlmRequest, got {other:?}"),
}
}
#[test]
fn llm_response_event_round_trips_through_jsonl() {
let response = CompletionResponse {
id: "msg_1".into(),
model: ModelId::new("claude-sonnet-4-5"),
content: vec![Content::text("Hello world")],
stop_reason: StopReason::EndTurn,
usage: Usage {
tokens_input: 25,
tokens_output: 15,
..Default::default()
},
};
let event = Event::new(
7,
RunId::new(),
SpanId::from("span_2"),
EventKind::LlmResponse(LlmResponsePayload { response }),
);
let bytes = serde_json::to_vec(&event).expect("serialize");
let back: Event = serde_json::from_slice(&bytes).expect("deserialize");
match back.kind {
EventKind::LlmResponse(p) => {
assert_eq!(p.response.id, "msg_1");
assert_eq!(p.response.content.len(), 1);
assert!(matches!(
&p.response.content[0],
Content::Text { text } if text == "Hello world"
));
}
other => panic!("expected LlmResponse, got {other:?}"),
}
}
}