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 four segment hashes, in prefix order.
107 #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
108 pub segments: Vec<B3Hash>,
109 /// The endpoint's own model id, as the request spelled it.
110 pub model: String,
111}
112
113/// `model_returned`: the reply, the size of the wave it asked for, and
114/// what the provider said it cost.
115///
116/// Every field but `message` and `calls` is absent when the provider
117/// did not report it, which is not the same fact as reporting zero.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120pub struct ModelReturned {
121 /// The assistant message, in the canonical content-block shape.
122 pub message: Payload,
123 /// How many tool calls the reply asked for.
124 pub calls: u64,
125 /// Four token counts in the one meaning `ModelUsage` states, the
126 /// dialect that reported them, and the meaning's version `v`.
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 #[cfg_attr(
129 feature = "schema",
130 schemars(with = "Option<std::collections::BTreeMap<String, serde_json::Value>>")
131 )]
132 pub usage: Option<ModelUsage>,
133 /// Why the provider stopped, when it said.
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
136 pub stop: Option<StopReason>,
137 /// What the provider itself billed, when it returns a figure; the
138 /// city's own price list is what fills the gap when it does not.
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub billed_usd_micros: Option<UsdMicros>,
141 /// When the reply's first non-empty prose or reasoning reached the
142 /// city, read from the turn's clock (`crates/kernel/spec/Event/Record.lean` ยง8-75). A moment,
143 /// not a duration: the time to first content is this minus the
144 /// `model_called` line's `t`. Absent when the reply came through a
145 /// door that reports nothing before it settles, when it streamed
146 /// only tool calls, and on every line written before the key
147 /// existed - none of which is a zero.
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub first_at: Option<TimeMs>,
150 /// Whole microseconds from the moment the attempt that returned went
151 /// out to its first non-empty prose or reasoning, on the turn's
152 /// monotonic clock (`crates/kernel/spec/Event/Record.lean` D20).
153 /// Absent exactly where `first_at` is.
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub first_us: Option<u64>,
156 /// Whole microseconds from the moment the attempt that returned went
157 /// out to the moment its reply was whole, on the same clock. Absent
158 /// on every line written before the key existed.
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub took_us: Option<u64>,
161}
162
163/// `steer_received`: text added to a run at a phase boundary, and who
164/// spoke it.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
167pub struct SteerReceived {
168 /// [`SteerReceived::PERSON_SOURCE`], [`SteerReceived::CITY_SOURCE`],
169 /// or a resident's `@<room>` followed by its letter's attributes.
170 pub source: String,
171 pub text: String,
172}
173
174impl SteerReceived {
175 /// The `source` of a steer the person spoke. `runtime::conversation`
176 /// writes it and `wire::note_of` reads it; the spelling lives here
177 /// because both depend on this crate and neither on the other
178 /// (`crates/wire/spec/Reading.lean` D41).
179 pub const PERSON_SOURCE: &'static str = "user";
180 /// The `source` of the city's own word to a run: a policy change, a
181 /// sync wait that ran out.
182 pub const CITY_SOURCE: &'static str = "city";
183}
184
185/// One part of a request's cache shape, as a record states it: how many
186/// bytes the part carries, and their hash.
187///
188/// Both figures because two questions read this row. A prompt cache
189/// compares the hash; a person asking why a request is expensive needs
190/// the bytes, which no hash can answer for.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
193pub struct ShapePart {
194 pub bytes: u64,
195 #[cfg_attr(feature = "schema", schemars(with = "String"))]
196 pub hash: B3Hash,
197}
198
199/// Whether one region of a request differs from the request before it.
200/// A closed pair rather than a bare `bool`, so a reader meets the word
201/// that says which way it went.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
205pub enum PartChange {
206 Same,
207 Moved,
208}
209
210/// What moved between one request's cache shape and the previous one.
211///
212/// `FirstRequest` is a variant rather than a silent "nothing moved": a
213/// run's first request has no earlier shape to compare against, and
214/// recording it as unchanged would read as a cache hit about to happen.
215/// A miss is explained by naming what moved, which is what the second
216/// variant is for.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(tag = "basis", rename_all = "snake_case")]
219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
220pub enum ShapeChanged {
221 FirstRequest,
222 Compared {
223 /// The frozen segments whose bytes differ, by slot name, in
224 /// prefix order. Empty means the system half is unchanged.
225 system: Vec<String>,
226 tools: PartChange,
227 run: PartChange,
228 },
229}
230
231/// `prompt_shape_compared`: one request's cache shape where a prompt
232/// cache looks at it, and which of its regions moved since the request
233/// before it in the same run.
234///
235/// The four segments are deliberately absent: `prompt_assembled`,
236/// written immediately before this line, already states each one's hash
237/// and length, and a second copy here would be a second home for them.
238/// What this adds are the two regions that record never names - the tool
239/// table and the conversation - and the comparison itself.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242pub struct PromptShapeCompared {
243 pub tools: ShapePart,
244 pub run: ShapePart,
245 /// The most bytes this request can be billed as input, counted from
246 /// the parts above and the segments `prompt_assembled` states, added
247 /// with checked arithmetic.
248 pub upper_bound: u64,
249 pub changed: ShapeChanged,
250}
251
252#[cfg(test)]
253#[allow(
254 clippy::unwrap_used,
255 clippy::expect_used,
256 clippy::panic,
257 clippy::indexing_slicing,
258 reason = "test code"
259)]
260mod tests;