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
//! P4c (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4" core NEW-significant item,
//! §1.10/§3.1 `core.model_switch.allow_switch`, D9 row): a persisted,
//! TYPED record of a mid-session model switch — pi's `model_change`
//! precedent (design §1.10: "persisted change records … pi's `model_change`
//! is the cleanest precedent"). Deliberately flat/typed (not a formatted
//! string), mirroring [`crate::usage_log::UsageRecord`]'s exact rationale:
//! a translatable, lossless session-data channel (§1.13), not a lossy
//! notice — so it survives a save/load round trip byte-for-byte in the
//! fields that matter, and a future reader (a translator emitting this same
//! session under another harness's format, a `doctor`/`inspect stats`
//! command) can consume it without re-parsing prose.
use serde::{Deserialize, Serialize};
/// One mid-session model switch, as `Agent::switch_model` records it when
/// `Config::model_switch_allow_switch` is on (see that field's doc comment
/// for the exact gate — with the knob off, `switch_model` never creates one
/// of these at all, matching `Agent::set_model`'s pre-existing,
/// record-free mechanics byte-for-byte).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelChangeRecord {
/// 0-based index of the model round-trip THIS switch takes effect
/// before (i.e. `Agent::turn_index` at the moment of the switch) — the
/// same per-turn addressing [`crate::usage_log::UsageRecord::turn`]
/// uses, so the two logs can be correlated.
pub turn: usize,
/// The model id in effect immediately before this switch.
pub from_model: String,
/// The model id in effect immediately after this switch.
pub to_model: String,
/// Whether `reduce::rehydrate::filter_reasoning_artifacts` ran over the
/// live history as part of this switch (dep 8) — always `true` when
/// this record exists at all (a record is only ever created under
/// `allow_switch = true`, which is the same gate that runs the filter),
/// but recorded explicitly rather than implied, so a reader of the
/// persisted log alone (without also knowing the config that produced
/// it) can see the safety guarantee held for this specific switch.
#[serde(default)]
pub reasoning_filtered: bool,
/// Count of `ChatMessage`s the reasoning-artifact filter actually
/// touched (stripped a metadata key from, or removed a `content_parts`
/// reasoning block from) during this switch. `0` is a legitimate,
/// common value (nothing to filter yet), not an error signal.
#[serde(default)]
pub reasoning_artifacts_filtered: usize,
/// Unix-ms wall-clock time the switch happened.
#[serde(default)]
pub timestamp_ms: i64,
/// BP-13: why this change happened, when it was NOT user-initiated —
/// the provider failure that made `Agent::run_loop` walk to the next
/// entry of `Config::model_fallback`. `None` for a deliberate switch
/// (`/model`), which needs no reason beyond the user having asked.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl ModelChangeRecord {
/// Build a record — the constructor `Agent::switch_model` calls.
pub fn new(
turn: usize,
from_model: impl Into<String>,
to_model: impl Into<String>,
reasoning_filtered: bool,
reasoning_artifacts_filtered: usize,
timestamp_ms: i64,
) -> ModelChangeRecord {
ModelChangeRecord {
turn,
from_model: from_model.into(),
to_model: to_model.into(),
reasoning_filtered,
reasoning_artifacts_filtered,
timestamp_ms,
reason: None,
}
}
/// BP-13: attach the non-user cause of this change (a fallback hop's
/// provider failure). A record with no reason describes a switch the
/// user asked for.
pub fn with_reason(mut self, reason: Option<String>) -> ModelChangeRecord {
self.reason = reason;
self
}
/// BP-13: whether this change was performed BY the loop rather than
/// asked for by the user.
pub fn automatic(&self) -> bool {
self.reason.is_some()
}
}
/// Serialize `records` as JSONL (one [`ModelChangeRecord`] per line) — the
/// same shape [`crate::usage_log::to_jsonl`] and every other append-log in
/// this crate uses. Never fails on an empty slice (produces an empty
/// string).
pub fn to_jsonl(records: &[ModelChangeRecord]) -> 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 model-change log back into records — the exact inverse of
/// [`to_jsonl`]. Blank lines are skipped; a malformed line is a hard error
/// (like [`crate::usage_log::from_jsonl`] — this is accounting/provenance
/// data, corruption should be visible, not silently dropped).
pub fn from_jsonl(text: &str) -> crate::Result<Vec<ModelChangeRecord>> {
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)
}