Skip to main content

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;