supercode_harness/turn_record.rs
1//! BP-7 (catalog §4a "Turn/step bracketing records", "Interrupt/abort with
2//! state preserved", "Auto-retry on transient provider errors"): the
3//! persisted per-round-trip marker log — cc's `turn_duration`/`api_retry`
4//! system records and cx's `turn_context`/`turn_aborted`/`responses_retry`
5//! rows, in one typed shape.
6//!
7//! **Where it lands.** `<session>.events.jsonl`, the sidecar-family member
8//! [`crate::store::SessionStore`] has always reserved and swept
9//! (archive/delete) but never had a writer for. So this is not a new store:
10//! it is the log that slot was cut for, filled in — beside the transcript,
11//! the reduction log, the usage log and the git-metadata record, exactly
12//! like every other family member (§1.13's "typed session data, never a
13//! lossy display-only channel").
14//!
15//! **Relationship to the usage log.** [`crate::usage_log::UsageRecord`] is
16//! the ACCOUNTING projection: one row per round-trip, aggregatable by a
17//! cost dashboard. This is the BRACKETING projection: what the request was
18//! built over (`Context`), what it cost (`Usage`), how the round-trip ended
19//! (`Finish`), and the two things that happen *between* round-trips
20//! (`Retry`, `Aborted`). They are written from the same points in
21//! `Agent::run_loop` and never disagree; keeping them separate keeps a
22//! usage-log reader from having to skip four record kinds it does not care
23//! about.
24
25use serde::{Deserialize, Serialize};
26
27/// Why a model round-trip (or a whole `send` loop) ended.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum FinishReason {
31 /// The assistant asked for tool calls; the loop continues.
32 ToolCalls,
33 /// The assistant produced a final answer and the loop returned.
34 EndTurn,
35 /// `core.max_iterations` was exhausted.
36 MaxIterations,
37 /// `core.max_total_output_tokens` was reached.
38 OutputTokenBudget,
39 /// `core.max_budget_usd` was reached.
40 SpendBudget,
41 /// `core.max_steps` was reached.
42 StepBudget,
43}
44
45impl FinishReason {
46 /// The wire spelling, for a reader that renders these without serde.
47 pub fn label(self) -> &'static str {
48 match self {
49 FinishReason::ToolCalls => "tool_calls",
50 FinishReason::EndTurn => "end_turn",
51 FinishReason::MaxIterations => "max_iterations",
52 FinishReason::OutputTokenBudget => "output_token_budget",
53 FinishReason::SpendBudget => "spend_budget",
54 FinishReason::StepBudget => "step_budget",
55 }
56 }
57}
58
59/// One bracketing marker. The `marker` tag is the record's kind.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(tag = "marker", rename_all = "snake_case")]
62pub enum TurnMarker {
63 /// Opens a round-trip: the shape of the context the request was built
64 /// over, captured BEFORE the request is issued (so it survives a
65 /// request that never returns).
66 Context {
67 /// Messages in the request.
68 messages: usize,
69 /// Tool schemas advertised on the request.
70 tools: usize,
71 /// Estimated prompt tokens (`supercode_runtime`' own estimator — the
72 /// same one the context guard uses, so the two never disagree).
73 estimated_tokens: u64,
74 },
75 /// The round-trip's provider-reported token accounting, plus its dollar
76 /// cost when the model is priceable ([`crate::pricing`]).
77 Usage {
78 /// Input tokens.
79 prompt_tokens: u64,
80 /// Output tokens.
81 completion_tokens: u64,
82 /// Provider-reported total.
83 total_tokens: u64,
84 /// Prompt tokens served from the provider's cache, if reported.
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 cached_tokens: Option<u64>,
87 /// Dollar cost, `None` when this build cannot price the model.
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 cost_usd: Option<f64>,
90 },
91 /// Closes a round-trip (or the loop).
92 Finish {
93 /// Why it ended.
94 reason: FinishReason,
95 },
96 /// A transient provider failure was retried with backoff. Written from
97 /// the notices the transport's own retry loop records, so a retried
98 /// request is visible in the log instead of being invisible the way the
99 /// ledger's `auto-retry-on-transient-provider-errors` row described.
100 Retry {
101 /// 0-based attempt index that FAILED (attempt 0 is the first try).
102 attempt: u32,
103 /// Backoff slept before the next attempt, milliseconds.
104 delay_ms: u64,
105 /// One-line reason (HTTP status or transport error).
106 reason: String,
107 },
108 /// The turn was interrupted (Ctrl-C / a cancelled `send` future). The
109 /// partial work already appended to the transcript stands; this marker
110 /// is what makes the interruption a FACT on reload rather than
111 /// something a reader has to infer from a dangling tool call.
112 Aborted {
113 /// Where the interruption came from (`"ctrl_c"`, `"cancelled"`, …).
114 source: String,
115 /// Messages in the agent's history at the moment of the abort.
116 messages: usize,
117 },
118 /// Reasoning effort changed mid-session (`/effort`), the extended-
119 /// thinking analog of the `model_change` log.
120 Effort {
121 /// Effort before the change (`None` = thinking off).
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 from: Option<String>,
124 /// Effort after the change (`None` = thinking off).
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 to: Option<String>,
127 },
128 /// The session's persistent objective was set, changed, or cleared
129 /// (`/goal`). The goal itself lives in `<session>.goal.json`; this is
130 /// the audit trail of when it moved.
131 Goal {
132 /// The objective after the change; empty means cleared.
133 objective: String,
134 },
135}
136
137/// One marker with the per-round-trip context every marker shares.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct TurnRecord {
140 /// 0-based index of the model round-trip this record brackets — the
141 /// same counter [`crate::usage_log::UsageRecord::turn`] uses, so the
142 /// two logs join on it.
143 pub turn: usize,
144 /// The model in effect when the marker was written.
145 pub model: String,
146 /// Unix-ms wall-clock time.
147 pub timestamp_ms: i64,
148 /// The marker itself.
149 #[serde(flatten)]
150 pub marker: TurnMarker,
151}
152
153impl TurnRecord {
154 /// Build a record for `marker`.
155 pub fn new(turn: usize, model: &str, timestamp_ms: i64, marker: TurnMarker) -> TurnRecord {
156 TurnRecord {
157 turn,
158 model: model.to_string(),
159 timestamp_ms,
160 marker,
161 }
162 }
163}
164
165/// Serialize `records` as JSONL — the same shape every other append-log in
166/// this crate uses.
167pub fn to_jsonl(records: &[TurnRecord]) -> crate::Result<String> {
168 let mut out = String::new();
169 for r in records {
170 out.push_str(&serde_json::to_string(r).map_err(crate::Error::Decode)?);
171 out.push('\n');
172 }
173 Ok(out)
174}
175
176/// Parse a JSONL marker log back into records — the exact inverse of
177/// [`to_jsonl`]. Blank lines are skipped; a malformed line is a hard error
178/// (this is an audit log, so a corrupt record should be visible, never
179/// silently dropped — same posture as [`crate::usage_log::from_jsonl`]).
180pub fn from_jsonl(text: &str) -> crate::Result<Vec<TurnRecord>> {
181 let mut out = Vec::new();
182 for line in text.lines() {
183 let line = line.trim();
184 if line.is_empty() {
185 continue;
186 }
187 out.push(serde_json::from_str(line).map_err(crate::Error::Decode)?);
188 }
189 Ok(out)
190}