kernel/event/record/turn.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! What one turn records: the prefix it assembled, the call it sent,
7//! the reply that came back, and the steer that arrived at a boundary.
8
9use serde::{Deserialize, Serialize};
10
11use crate::address::Address;
12use crate::budget::UsdMicros;
13use crate::event::identity::TimeMs;
14use crate::event::payload::Payload;
15use crate::locator::B3Hash;
16use crate::model::{ModelUsage, StopReason};
17
18/// `prompt_assembled`: the four segments a request was built from, and
19/// the cache breakpoints it carried.
20///
21/// This is the line an offline rebuild reads (A15): every segment says
22/// which documents went into it and how much of each one survived the
23/// slot's byte cap, so the bytes the model read can be reconstructed
24/// from the files without the request being kept.
25#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27pub struct PromptAssembled {
28 #[serde(default)]
29 pub segments: Vec<PromptSegment>,
30 /// The explicit cache breakpoints the request carried, in request
31 /// order: a slot name for a segment edge, `tail` for the last
32 /// message. The plan has one author, `runtime::prefix::BreakpointPlan`,
33 /// and this key carries what that author printed. A ledger written
34 /// before the plan existed holds the four slot names here instead.
35 #[serde(default)]
36 pub breakpoints: Vec<String>,
37}
38
39/// One frozen segment, as `prompt_assembled` states it.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub struct PromptSegment {
43 pub slot: String,
44 #[cfg_attr(feature = "schema", schemars(with = "String"))]
45 pub hash: B3Hash,
46 /// Bytes this segment contributed to the request.
47 pub len: u64,
48 /// The documents behind the segment, in concatenation order.
49 /// Written even when empty: a segment built from an identity rather
50 /// than from files names none, and that is a fact rather than an
51 /// omission.
52 #[serde(default)]
53 pub sources: Vec<PromptSource>,
54 /// The documents the slot was offered and did not take.
55 #[serde(default)]
56 pub skipped: Vec<PromptSkip>,
57}
58
59/// One document a segment was assembled from.
60///
61/// `kept` and `dropped` state both what the model read and what the cap
62/// cut off; `marker` says a truncation marker follows the kept bytes,
63/// which the byte counts alone cannot say.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66pub struct PromptSource {
67 pub addr: Address,
68 pub kept: u64,
69 pub marker: bool,
70 pub dropped: u64,
71}
72
73/// One document a slot was offered and left out, and why.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76pub struct PromptSkip {
77 pub addr: Address,
78 pub reason: SkipReason,
79}
80
81/// Why a document did not reach its segment. Closed: an assembler that
82/// finds a fifth reason states it here, where every reader sees it.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
86pub enum SkipReason {
87 /// An earlier slot already carried this document; the first slot
88 /// wins, because the earlier slot is the more cacheable one.
89 Duplicate,
90 /// The resolver returned no bytes for the address.
91 Unreadable,
92 /// The bytes are not UTF-8, and a prompt is text.
93 NotUtf8,
94 /// The slot's byte cap was already spent.
95 NoBudget,
96}
97
98/// `model_called`: which prefix went out, under which model name.
99///
100/// The request body is deliberately absent: it is reconstructible from
101/// [`PromptAssembled`] plus the window, and a copy of it here would be
102/// the second home of every message the run ever sent.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105pub struct ModelCalled {
106 /// The account attempted, never its credential or reference.
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub provider_account: Option<super::ProviderAccountBinding>,
109 /// The four segment hashes, in prefix order.
110 #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
111 #[serde(default)]
112 pub segments: Vec<B3Hash>,
113 /// The endpoint's own model id; absent in legacy incomplete call records.
114 #[serde(default)]
115 pub model: String,
116}
117
118/// `model_returned`: the reply, the size of the wave it asked for, and
119/// what the provider said it cost.
120///
121/// Every field but `message` and `calls` is absent when the provider
122/// did not report it, which is not the same fact as reporting zero.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
125pub struct ModelReturned {
126 /// The assistant message, in the canonical content-block shape.
127 pub message: Payload,
128 /// How many tool calls the reply asked for.
129 pub calls: u64,
130 /// Four token counts in the one meaning `ModelUsage` states, the
131 /// dialect that reported them, and the meaning's version `v`.
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 #[cfg_attr(
134 feature = "schema",
135 schemars(with = "Option<std::collections::BTreeMap<String, serde_json::Value>>")
136 )]
137 pub usage: Option<ModelUsage>,
138 /// Why the provider stopped, when it said.
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
141 pub stop: Option<StopReason>,
142 /// What the provider itself billed, when it returns a figure; the
143 /// city's own price list is what fills the gap when it does not.
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub billed_usd_micros: Option<UsdMicros>,
146 /// When the reply's first non-empty prose or reasoning reached the
147 /// city, read from the turn's clock (`crates/kernel/spec/Event/Record.lean` ยง8-75). A moment,
148 /// not a duration: the time to first content is this minus the
149 /// `model_called` line's `t`. Absent when the reply came through a
150 /// door that reports nothing before it settles, when it streamed
151 /// only tool calls, and on every line written before the key
152 /// existed - none of which is a zero.
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub first_at: Option<TimeMs>,
155 /// Whole microseconds from the moment the attempt that returned went
156 /// out to its first non-empty prose or reasoning, on the turn's
157 /// monotonic clock (`crates/kernel/spec/Event/Record.lean` D20).
158 /// Absent exactly where `first_at` is.
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub first_us: Option<u64>,
161 /// Whole microseconds from the moment the attempt that returned went
162 /// out to the moment its reply was whole, on the same clock. Absent
163 /// on every line written before the key existed.
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub took_us: Option<u64>,
166}
167
168/// `steer_received`: text added to a run at a phase boundary, and who
169/// spoke it.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172pub struct SteerReceived {
173 /// [`SteerReceived::PERSON_SOURCE`], [`SteerReceived::CITY_SOURCE`],
174 /// or a resident's `@<room>` followed by its letter's attributes.
175 pub source: String,
176 pub text: String,
177}
178
179impl SteerReceived {
180 /// The `source` of a steer the person spoke. `runtime::conversation`
181 /// writes it and `wire::note_of` reads it; the spelling lives here
182 /// because both depend on this crate and neither on the other
183 /// (`crates/wire/spec/Reading.lean` D41).
184 pub const PERSON_SOURCE: &'static str = "user";
185 /// The `source` of the city's own word to a run: a policy change, a
186 /// sync wait that ran out.
187 pub const CITY_SOURCE: &'static str = "city";
188}
189
190/// One part of a request's cache shape, as a record states it: how many
191/// bytes the part carries, and their hash.
192///
193/// Both figures because two questions read this row. A prompt cache
194/// compares the hash; a person asking why a request is expensive needs
195/// the bytes, which no hash can answer for.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
198pub struct ShapePart {
199 pub bytes: u64,
200 #[cfg_attr(feature = "schema", schemars(with = "String"))]
201 pub hash: B3Hash,
202}
203
204/// Whether one region of a request differs from the request before it.
205/// A closed pair rather than a bare `bool`, so a reader meets the word
206/// that says which way it went.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
210pub enum PartChange {
211 Same,
212 Moved,
213}
214
215/// What moved between one request's cache shape and the previous one.
216///
217/// `FirstRequest` is a variant rather than a silent "nothing moved": a
218/// run's first request has no earlier shape to compare against, and
219/// recording it as unchanged would read as a cache hit about to happen.
220/// A miss is explained by naming what moved, which is what the second
221/// variant is for.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(tag = "basis", rename_all = "snake_case")]
224#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
225pub enum ShapeChanged {
226 FirstRequest,
227 Compared {
228 /// The frozen segments whose bytes differ, by slot name, in
229 /// prefix order. Empty means the system half is unchanged.
230 system: Vec<String>,
231 tools: PartChange,
232 run: PartChange,
233 },
234}
235
236/// `prompt_shape_compared`: one request's cache shape where a prompt
237/// cache looks at it, and which of its regions moved since the request
238/// before it in the same run.
239///
240/// The four segments are deliberately absent: `prompt_assembled`,
241/// written immediately before this line, already states each one's hash
242/// and length, and a second copy here would be a second home for them.
243/// What this adds are the two regions that record never names - the tool
244/// table and the conversation - and the comparison itself.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
247pub struct PromptShapeCompared {
248 pub tools: ShapePart,
249 pub run: ShapePart,
250 /// The most bytes this request can be billed as input, counted from
251 /// the parts above and the segments `prompt_assembled` states, added
252 /// with checked arithmetic.
253 pub upper_bound: u64,
254 pub changed: ShapeChanged,
255}
256
257#[cfg(test)]
258#[allow(
259 clippy::unwrap_used,
260 clippy::expect_used,
261 clippy::panic,
262 clippy::indexing_slicing,
263 reason = "test code"
264)]
265mod tests;