Skip to main content

oharness_core/
event.rs

1//! Event schema (§4.7). The JSONL format is the source of truth for trajectory files.
2
3use crate::capabilities::LlmCapabilities;
4use crate::completion::{CompletionRequest, CompletionResponse, StopReason, Usage};
5use crate::context::NamespaceError;
6use crate::ids::{RunId, SpanId};
7use crate::task::Task;
8use crate::MetadataMap;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use time::OffsetDateTime;
12
13/// Semver-tagged schema version. Additive changes bump minor; breaking changes bump
14/// major (and become v2+). See §19.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct SchemaVersion {
17    pub major: u16,
18    pub minor: u16,
19}
20
21impl SchemaVersion {
22    pub const CURRENT: SchemaVersion = SchemaVersion { major: 1, minor: 0 };
23}
24
25impl std::fmt::Display for SchemaVersion {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        write!(f, "{}.{}", self.major, self.minor)
28    }
29}
30
31impl Serialize for SchemaVersion {
32    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
33        s.serialize_str(&self.to_string())
34    }
35}
36
37impl<'de> Deserialize<'de> for SchemaVersion {
38    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
39        let s = String::deserialize(d)?;
40        let (maj, min) = s
41            .split_once('.')
42            .ok_or_else(|| serde::de::Error::custom("schema version must be `MAJOR.MINOR`"))?;
43        let major = maj.parse().map_err(serde::de::Error::custom)?;
44        let minor = min.parse().map_err(serde::de::Error::custom)?;
45        Ok(SchemaVersion { major, minor })
46    }
47}
48
49#[cfg(feature = "schemars-export")]
50impl schemars::JsonSchema for SchemaVersion {
51    fn schema_name() -> String {
52        "SchemaVersion".into()
53    }
54    fn json_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
55        // Serialized as "MAJOR.MINOR" (see Serialize / Deserialize
56        // above). Pin the pattern so schema consumers reject malformed
57        // values rather than accepting any string.
58        schemars::schema::SchemaObject {
59            instance_type: Some(schemars::schema::InstanceType::String.into()),
60            string: Some(Box::new(schemars::schema::StringValidation {
61                pattern: Some(r"^\d+\.\d+$".into()),
62                ..Default::default()
63            })),
64            ..Default::default()
65        }
66        .into()
67    }
68}
69
70/// Top-level event envelope. Every event — lifecycle, LLM, tool, memory, etc. — is
71/// wrapped in this struct. Spans are represented by two events sharing a `span_id`.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
74pub struct Event {
75    pub v: SchemaVersion,
76    pub seq: u64,
77    pub run_id: RunId,
78    #[serde(
79        default,
80        skip_serializing_if = "Option::is_none",
81        with = "time::serde::rfc3339::option"
82    )]
83    #[cfg_attr(feature = "schemars-export", schemars(with = "Option<String>"))]
84    pub timestamp: Option<OffsetDateTime>,
85    pub span_id: SpanId,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub parent: Option<u64>,
88    /// Flattens `type` and `payload` fields into the envelope. Unknown types deserialize
89    /// as `EventKind::Unknown` carrying the raw payload — a forward-compat contract.
90    #[serde(flatten)]
91    pub kind: EventKind,
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub redactions: Vec<String>,
94}
95
96impl Event {
97    pub fn new(seq: u64, run_id: RunId, span_id: impl Into<SpanId>, kind: EventKind) -> Self {
98        Self {
99            v: SchemaVersion::CURRENT,
100            seq,
101            run_id,
102            timestamp: Some(OffsetDateTime::now_utc()),
103            span_id: span_id.into(),
104            parent: None,
105            kind,
106            redactions: Vec::new(),
107        }
108    }
109
110    pub fn with_parent(mut self, parent: u64) -> Self {
111        self.parent = Some(parent);
112        self
113    }
114}
115
116/// Discriminated event catalog. `type` field is the serde tag; `payload` holds the
117/// variant's data. New variants are additive per schema versioning rules.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
120#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
121#[non_exhaustive]
122pub enum EventKind {
123    #[serde(rename = "meta")]
124    Meta(MetaPayload),
125
126    #[serde(rename = "run.started")]
127    RunStarted(RunStartedPayload),
128    #[serde(rename = "run.finished")]
129    RunFinished(RunFinishedPayload),
130
131    #[serde(rename = "turn.started")]
132    TurnStarted(TurnPayload),
133    #[serde(rename = "turn.finished")]
134    TurnFinished(TurnFinishedPayload),
135    #[serde(rename = "turn.revised")]
136    TurnRevised(TurnRevisedPayload),
137
138    #[serde(rename = "llm.request")]
139    LlmRequest(LlmRequestPayload),
140    #[serde(rename = "llm.response")]
141    LlmResponse(LlmResponsePayload),
142    #[serde(rename = "llm.stream.chunk")]
143    LlmStreamChunk(Value),
144    #[serde(rename = "llm.retry")]
145    LlmRetry(LlmRetryPayload),
146    #[serde(rename = "llm.failed")]
147    LlmFailed(LlmFailedPayload),
148
149    #[serde(rename = "tool.call.started")]
150    ToolCallStarted(ToolCallStartedPayload),
151    #[serde(rename = "tool.call.finished")]
152    ToolCallFinished(ToolCallFinishedPayload),
153    #[serde(rename = "tool.call.failed")]
154    ToolCallFailed(ToolCallFailedPayload),
155    #[serde(rename = "tool.approval.requested")]
156    ToolApprovalRequested(Value),
157    #[serde(rename = "tool.approval.decided")]
158    ToolApprovalDecided(Value),
159
160    #[serde(rename = "memory.evicted")]
161    MemoryEvicted(Value),
162    #[serde(rename = "memory.summarized")]
163    MemorySummarized(Value),
164    #[serde(rename = "memory.retrieved")]
165    MemoryRetrieved(Value),
166
167    #[serde(rename = "budget.exceeded")]
168    BudgetExceeded(Value),
169
170    #[serde(rename = "policy.input.checked")]
171    PolicyInputChecked(Value),
172    #[serde(rename = "policy.output.checked")]
173    PolicyOutputChecked(Value),
174    #[serde(rename = "policy.blocked")]
175    PolicyBlocked(Value),
176
177    #[serde(rename = "planner.proposed")]
178    PlannerProposed(Value),
179    #[serde(rename = "planner.revised")]
180    PlannerRevised(Value),
181    #[serde(rename = "planner.committed")]
182    PlannerCommitted(Value),
183
184    #[serde(rename = "critic.assessed")]
185    CriticAssessed(Value),
186    #[serde(rename = "critic.rejected")]
187    CriticRejected(Value),
188    #[serde(rename = "critic.revised")]
189    CriticRevised(Value),
190    #[serde(rename = "critic.failed")]
191    CriticFailed(Value),
192
193    #[serde(rename = "reflection.generated")]
194    ReflectionGenerated(Value),
195    #[serde(rename = "reflection.injected")]
196    ReflectionInjected(Value),
197
198    #[serde(rename = "human.interrupt")]
199    HumanInterrupt(Value),
200    #[serde(rename = "human.inject")]
201    HumanInject(Value),
202
203    #[serde(rename = "user.simulated.message")]
204    UserSimulatedMessage(Value),
205    #[serde(rename = "user.simulated.ended")]
206    UserSimulatedEnded(Value),
207
208    /// Escape hatch for user-defined events. Namespace MUST NOT start with a built-in
209    /// category prefix. Construct via `EventKind::user_log`.
210    #[serde(rename = "user.log")]
211    UserLog(UserLogPayload),
212
213    /// Forward-compat fallback: unknown event type preserved verbatim.
214    #[serde(other)]
215    Unknown,
216}
217
218/// Namespaces reserved for built-in categories — `user.log` namespaces may not collide.
219pub const RESERVED_NAMESPACE_PREFIXES: &[&str] = &[
220    "run.",
221    "turn.",
222    "llm.",
223    "tool.",
224    "memory.",
225    "budget.",
226    "policy.",
227    "planner.",
228    "critic.",
229    "reflection.",
230    "human.",
231    "user.simulated.",
232    "meta.",
233];
234
235impl EventKind {
236    /// Construct a `user.log` event. Returns an error if the namespace is empty or
237    /// collides with a built-in category prefix.
238    pub fn user_log(namespace: impl Into<String>, data: Value) -> Result<Self, NamespaceError> {
239        let namespace = namespace.into();
240        if namespace.is_empty() {
241            return Err(NamespaceError::Empty);
242        }
243        // A namespace collides if it equals or is prefixed by a reserved category.
244        for reserved in RESERVED_NAMESPACE_PREFIXES {
245            let bare = reserved.trim_end_matches('.');
246            if namespace == bare || namespace.starts_with(reserved) {
247                return Err(NamespaceError::BuiltinCollision(namespace));
248            }
249        }
250        Ok(EventKind::UserLog(UserLogPayload { namespace, data }))
251    }
252}
253
254// -- payload structs ----------------------------------------------------------
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
258pub struct MetaPayload {
259    pub schema_version: SchemaVersion,
260    pub harness_version: String,
261    pub task_snapshot: Task,
262    pub llm_capabilities: LlmCapabilities,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
267pub struct RunStartedPayload {
268    #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
269    pub extra: MetadataMap,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
274pub struct RunFinishedPayload {
275    pub termination: String,
276    pub turns: u32,
277    pub tool_calls: u32,
278    #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
279    pub extra: MetadataMap,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
284pub struct TurnPayload {
285    pub turn_index: u32,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
290pub struct TurnFinishedPayload {
291    pub turn_index: u32,
292    pub stop_reason: StopReason,
293    pub usage: Usage,
294    pub tool_calls: u32,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
298#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
299pub struct TurnRevisedPayload {
300    pub original_seq: u64,
301    pub replacement_seq: u64,
302    pub reason: String,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
306#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
307pub struct LlmRequestPayload {
308    pub request: CompletionRequest,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub provider: Option<String>,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
314#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
315pub struct LlmResponsePayload {
316    pub response: CompletionResponse,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
320#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
321pub struct LlmRetryPayload {
322    pub attempt: u32,
323    pub reason: String,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
328pub struct LlmFailedPayload {
329    pub reason: String,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
334pub struct ToolCallStartedPayload {
335    pub tool_name: String,
336    pub tool_use_id: String,
337    pub input: Value,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
342pub struct ToolCallFinishedPayload {
343    pub tool_name: String,
344    pub tool_use_id: String,
345    pub output: Value,
346    #[serde(default)]
347    pub truncated: bool,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
351#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
352pub struct ToolCallFailedPayload {
353    pub tool_name: String,
354    pub tool_use_id: String,
355    pub reason: String,
356    #[serde(default)]
357    pub recoverable: bool,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
361#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
362pub struct UserLogPayload {
363    pub namespace: String,
364    #[serde(flatten)]
365    pub data: Value,
366}
367
368/// Shim alias so callers that want to pattern-match on `EventPayload::X` can import
369/// the payload types through one re-export. (Not strictly needed; kept for ergonomics.)
370pub type EventPayload = Value;
371
372/// Errors encountered constructing events (currently: namespace collisions).
373pub type EventConstructionError = NamespaceError;
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::ids::ModelId;
379    use crate::message::{Content, Message};
380    use serde_json::json;
381
382    fn sample_request() -> CompletionRequest {
383        CompletionRequest {
384            messages: vec![Message::Assistant {
385                content: vec![
386                    Content::text("Let me look around."),
387                    Content::ToolUse {
388                        id: "tu_1".into(),
389                        name: "fs_list".into(),
390                        input: json!({"path": "."}),
391                    },
392                ],
393                stop_reason: Some(StopReason::ToolUse),
394                meta: MetadataMap::new(),
395            }],
396            tools: Vec::new(),
397            system: None,
398            max_tokens: Some(1024),
399            temperature: None,
400            stop_sequences: Vec::new(),
401            cache_hints: Default::default(),
402            extensions: MetadataMap::new(),
403        }
404    }
405
406    #[test]
407    fn llm_request_event_round_trips_through_jsonl() {
408        let req = sample_request();
409        let event = Event::new(
410            42,
411            RunId::new(),
412            SpanId::from("span_1"),
413            EventKind::LlmRequest(LlmRequestPayload {
414                request: req,
415                provider: Some("anthropic".into()),
416            }),
417        );
418        let bytes = serde_json::to_vec(&event).expect("serialize");
419        let back: Event = serde_json::from_slice(&bytes).expect("deserialize");
420        assert_eq!(back.seq, 42);
421        match back.kind {
422            EventKind::LlmRequest(p) => {
423                assert_eq!(p.provider.as_deref(), Some("anthropic"));
424                assert_eq!(p.request.messages.len(), 1);
425            }
426            other => panic!("expected LlmRequest, got {other:?}"),
427        }
428    }
429
430    #[test]
431    fn llm_response_event_round_trips_through_jsonl() {
432        let response = CompletionResponse {
433            id: "msg_1".into(),
434            model: ModelId::new("claude-sonnet-4-5"),
435            content: vec![Content::text("Hello world")],
436            stop_reason: StopReason::EndTurn,
437            usage: Usage {
438                tokens_input: 25,
439                tokens_output: 15,
440                ..Default::default()
441            },
442        };
443        let event = Event::new(
444            7,
445            RunId::new(),
446            SpanId::from("span_2"),
447            EventKind::LlmResponse(LlmResponsePayload { response }),
448        );
449        let bytes = serde_json::to_vec(&event).expect("serialize");
450        let back: Event = serde_json::from_slice(&bytes).expect("deserialize");
451        match back.kind {
452            EventKind::LlmResponse(p) => {
453                assert_eq!(p.response.id, "msg_1");
454                assert_eq!(p.response.content.len(), 1);
455                assert!(matches!(
456                    &p.response.content[0],
457                    Content::Text { text } if text == "Hello world"
458                ));
459            }
460            other => panic!("expected LlmResponse, got {other:?}"),
461        }
462    }
463}