1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
//! BP-7 (catalog §4a "Turn/step bracketing records", "Interrupt/abort with
//! state preserved", "Auto-retry on transient provider errors"): the
//! persisted per-round-trip marker log — cc's `turn_duration`/`api_retry`
//! system records and cx's `turn_context`/`turn_aborted`/`responses_retry`
//! rows, in one typed shape.
//!
//! **Where it lands.** `<session>.events.jsonl`, the sidecar-family member
//! [`crate::store::SessionStore`] has always reserved and swept
//! (archive/delete) but never had a writer for. So this is not a new store:
//! it is the log that slot was cut for, filled in — beside the transcript,
//! the reduction log, the usage log and the git-metadata record, exactly
//! like every other family member (§1.13's "typed session data, never a
//! lossy display-only channel").
//!
//! **Relationship to the usage log.** [`crate::usage_log::UsageRecord`] is
//! the ACCOUNTING projection: one row per round-trip, aggregatable by a
//! cost dashboard. This is the BRACKETING projection: what the request was
//! built over (`Context`), what it cost (`Usage`), how the round-trip ended
//! (`Finish`), and the two things that happen *between* round-trips
//! (`Retry`, `Aborted`). They are written from the same points in
//! `Agent::run_loop` and never disagree; keeping them separate keeps a
//! usage-log reader from having to skip four record kinds it does not care
//! about.
use serde::{Deserialize, Serialize};
/// Why a model round-trip (or a whole `send` loop) ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FinishReason {
/// The assistant asked for tool calls; the loop continues.
ToolCalls,
/// The assistant produced a final answer and the loop returned.
EndTurn,
/// `core.max_iterations` was exhausted.
MaxIterations,
/// `core.max_total_output_tokens` was reached.
OutputTokenBudget,
/// `core.max_budget_usd` was reached.
SpendBudget,
/// `core.max_steps` was reached.
StepBudget,
}
impl FinishReason {
/// The wire spelling, for a reader that renders these without serde.
pub fn label(self) -> &'static str {
match self {
FinishReason::ToolCalls => "tool_calls",
FinishReason::EndTurn => "end_turn",
FinishReason::MaxIterations => "max_iterations",
FinishReason::OutputTokenBudget => "output_token_budget",
FinishReason::SpendBudget => "spend_budget",
FinishReason::StepBudget => "step_budget",
}
}
}
/// One bracketing marker. The `marker` tag is the record's kind.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "marker", rename_all = "snake_case")]
pub enum TurnMarker {
/// Opens a round-trip: the shape of the context the request was built
/// over, captured BEFORE the request is issued (so it survives a
/// request that never returns).
Context {
/// Messages in the request.
messages: usize,
/// Tool schemas advertised on the request.
tools: usize,
/// Estimated prompt tokens (`crate::tokens`' own estimator — the
/// same one the context guard uses, so the two never disagree).
estimated_tokens: u64,
},
/// The round-trip's provider-reported token accounting, plus its dollar
/// cost when the model is priceable ([`crate::pricing`]).
Usage {
/// Input tokens.
prompt_tokens: u64,
/// Output tokens.
completion_tokens: u64,
/// Provider-reported total.
total_tokens: u64,
/// Prompt tokens served from the provider's cache, if reported.
#[serde(default, skip_serializing_if = "Option::is_none")]
cached_tokens: Option<u64>,
/// Dollar cost, `None` when this build cannot price the model.
#[serde(default, skip_serializing_if = "Option::is_none")]
cost_usd: Option<f64>,
},
/// Closes a round-trip (or the loop).
Finish {
/// Why it ended.
reason: FinishReason,
},
/// A transient provider failure was retried with backoff. Written from
/// the notices the transport's own retry loop records, so a retried
/// request is visible in the log instead of being invisible the way the
/// ledger's `auto-retry-on-transient-provider-errors` row described.
Retry {
/// 0-based attempt index that FAILED (attempt 0 is the first try).
attempt: u32,
/// Backoff slept before the next attempt, milliseconds.
delay_ms: u64,
/// One-line reason (HTTP status or transport error).
reason: String,
},
/// The turn was interrupted (Ctrl-C / a cancelled `send` future). The
/// partial work already appended to the transcript stands; this marker
/// is what makes the interruption a FACT on reload rather than
/// something a reader has to infer from a dangling tool call.
Aborted {
/// Where the interruption came from (`"ctrl_c"`, `"cancelled"`, …).
source: String,
/// Messages in the agent's history at the moment of the abort.
messages: usize,
},
/// Reasoning effort changed mid-session (`/effort`), the extended-
/// thinking analog of the `model_change` log.
Effort {
/// Effort before the change (`None` = thinking off).
#[serde(default, skip_serializing_if = "Option::is_none")]
from: Option<String>,
/// Effort after the change (`None` = thinking off).
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<String>,
},
/// The session's persistent objective was set, changed, or cleared
/// (`/goal`). The goal itself lives in `<session>.goal.json`; this is
/// the audit trail of when it moved.
Goal {
/// The objective after the change; empty means cleared.
objective: String,
},
}
/// One marker with the per-round-trip context every marker shares.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TurnRecord {
/// 0-based index of the model round-trip this record brackets — the
/// same counter [`crate::usage_log::UsageRecord::turn`] uses, so the
/// two logs join on it.
pub turn: usize,
/// The model in effect when the marker was written.
pub model: String,
/// Unix-ms wall-clock time.
pub timestamp_ms: i64,
/// The marker itself.
#[serde(flatten)]
pub marker: TurnMarker,
}
impl TurnRecord {
/// Build a record for `marker`.
pub fn new(turn: usize, model: &str, timestamp_ms: i64, marker: TurnMarker) -> TurnRecord {
TurnRecord {
turn,
model: model.to_string(),
timestamp_ms,
marker,
}
}
}
/// Serialize `records` as JSONL — the same shape every other append-log in
/// this crate uses.
pub fn to_jsonl(records: &[TurnRecord]) -> crate::Result<String> {
let mut out = String::new();
for r in records {
out.push_str(&serde_json::to_string(r).map_err(crate::Error::Decode)?);
out.push('\n');
}
Ok(out)
}
/// Parse a JSONL marker log back into records — the exact inverse of
/// [`to_jsonl`]. Blank lines are skipped; a malformed line is a hard error
/// (this is an audit log, so a corrupt record should be visible, never
/// silently dropped — same posture as [`crate::usage_log::from_jsonl`]).
pub fn from_jsonl(text: &str) -> crate::Result<Vec<TurnRecord>> {
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
out.push(serde_json::from_str(line).map_err(crate::Error::Decode)?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<TurnRecord> {
vec![
TurnRecord::new(
0,
"anthropic/claude-opus-4-8",
1_700_000_000_000,
TurnMarker::Context {
messages: 3,
tools: 4,
estimated_tokens: 1200,
},
),
TurnRecord::new(
0,
"anthropic/claude-opus-4-8",
1_700_000_001_000,
TurnMarker::Usage {
prompt_tokens: 1000,
completion_tokens: 50,
total_tokens: 1050,
cached_tokens: Some(200),
cost_usd: Some(0.01875),
},
),
TurnRecord::new(
0,
"anthropic/claude-opus-4-8",
1_700_000_001_100,
TurnMarker::Finish {
reason: FinishReason::EndTurn,
},
),
TurnRecord::new(
1,
"anthropic/claude-opus-4-8",
1_700_000_002_000,
TurnMarker::Retry {
attempt: 0,
delay_ms: 500,
reason: "provider status 503".to_string(),
},
),
TurnRecord::new(
1,
"anthropic/claude-opus-4-8",
1_700_000_003_000,
TurnMarker::Aborted {
source: "ctrl_c".to_string(),
messages: 7,
},
),
TurnRecord::new(
2,
"anthropic/claude-opus-4-8",
1_700_000_004_000,
TurnMarker::Effort {
from: Some("medium".to_string()),
to: None,
},
),
TurnRecord::new(
2,
"anthropic/claude-opus-4-8",
1_700_000_005_000,
TurnMarker::Goal {
objective: "ship BP-7".to_string(),
},
),
]
}
#[test]
fn jsonl_round_trip_is_lossless_for_every_marker_kind() {
let records = sample();
let jsonl = to_jsonl(&records).unwrap();
assert_eq!(jsonl.lines().count(), records.len());
assert_eq!(from_jsonl(&jsonl).unwrap(), records);
}
#[test]
fn the_marker_tag_names_the_kind_on_the_wire() {
let jsonl = to_jsonl(&sample()).unwrap();
let kinds: Vec<String> = jsonl
.lines()
.map(|l| {
serde_json::from_str::<serde_json::Value>(l).unwrap()["marker"]
.as_str()
.unwrap()
.to_string()
})
.collect();
assert_eq!(
kinds,
vec!["context", "usage", "finish", "retry", "aborted", "effort", "goal"]
);
}
#[test]
fn a_record_carries_the_turn_index_the_usage_log_joins_on() {
let v: serde_json::Value =
serde_json::from_str(to_jsonl(&sample()).unwrap().lines().next().unwrap()).unwrap();
assert_eq!(v["turn"], 0);
assert_eq!(v["model"], "anthropic/claude-opus-4-8");
assert_eq!(v["messages"], 3);
}
#[test]
fn empty_and_blank_input_round_trip_to_empty() {
assert_eq!(to_jsonl(&[]).unwrap(), "");
assert_eq!(from_jsonl("\n\n").unwrap(), Vec::<TurnRecord>::new());
}
#[test]
fn a_malformed_line_is_an_error_not_a_silent_drop() {
assert!(from_jsonl("{\"turn\":0}\n").is_err());
}
#[test]
fn finish_reason_labels_match_the_wire_spelling() {
for reason in [
FinishReason::ToolCalls,
FinishReason::EndTurn,
FinishReason::MaxIterations,
FinishReason::OutputTokenBudget,
FinishReason::SpendBudget,
FinishReason::StepBudget,
] {
let v = serde_json::to_value(reason).unwrap();
assert_eq!(v.as_str(), Some(reason.label()));
}
}
}