Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
11 kB · 265 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266//! Reading the current turn's model out of a session transcript.//!//! There is no `model` field on a hook payload and no `CLAUDE_MODEL` in the//! environment, so the only place the harness says which model is acting is//! the transcript it is already writing. Every payload carries//! [`transcript_path`](crate::CommonInput::transcript_path), and that file is//! JSONL whose assistant rows carry `message.model`. The last such row is the//! model in force for the turn that is about to call a tool. `SubagentStop`//! and a subagent's own `PreToolUse` carry the subagent's transcript, so the//! same read answers for a subagent.//!//! # This format is not a contract//!//! The transcript is a harness-internal file. Nothing documents its rows,//! nothing promises they will keep their shape, and a release could rename//! `message.model`, nest it, or stop writing it. So every function here//! returns [`Option`] and every failure — a path that does not exist, a file//! that is empty, a final line cut off mid-write, a row in a shape this code//! has never seen — is the same answer: `None`, and the scrobble goes out//! without a model on it.//!//! # Why it reads from the end//!//! A long session's transcript is tens of megabytes, and this runs inside the//! harness's critical path on the way to a tool call. Only the tail is read,//! and only [`MAX_ROWS_SCANNED`] rows of it are parsed, so the cost is//! bounded by two constants rather than by how long the developer has been//! working. The row nearest the end wins, which is also the row that answers//! the question: a session whose model was switched mid-conversation reports//! the model it is on now.
use std::fs::File;use std::io::{Read, Seek, SeekFrom};use std::path::Path;
/// How many bytes at the end of a transcript are read.////// Generous enough to hold many rows even when recent ones carry large tool/// results, small enough that reading it is one page-cache hit rather than a/// pause the developer notices.pub const TAIL_BYTES: u64 = 128 * 1024;
/// How many rows, counting back from the end, are parsed.////// A bound on work rather than on correctness: the assistant row this is/// looking for is normally the last row or within a few of it, and a/// transcript whose most recent assistant row is further back than this is one/// where the answer has gone stale anyway.pub const MAX_ROWS_SCANNED: usize = 64;
/// The lexicon's `maxLength` for a scrobble's `model`.////// Enforced here as well as at the server, because a value this reader cannot/// carry onto a record is a value it should not stamp: a rejected record loses/// the agent's sentence, and the model identifier was the least important/// thing on it.pub const MODEL_MAX_LENGTH: usize = 64;
/// The model identifier in force for the turn, read from `transcript_path`.////// Returns `None` for every way this can fail; see the module documentation.////// ```/// use didbot_hook::transcript::model_for_turn;/// assert!(model_for_turn(std::path::Path::new("/nonexistent/transcript.jsonl")).is_none());/// ```pub fn model_for_turn(transcript_path: &Path) -> Option<String> { model_in_tail(&tail(transcript_path)?)}
/// Reads at most [`TAIL_BYTES`] from the end of a file.////// Decoded lossily: seeking to a byte offset lands in the middle of a line and/// may land in the middle of a character. The damage is confined to the first/// line of what is returned, which [`model_in_tail`] discards anyway by/// failing to parse it.fn tail(path: &Path) -> Option<String> { let mut file = File::open(path).ok()?; let len = file.metadata().ok()?.len(); file.seek(SeekFrom::Start(len.saturating_sub(TAIL_BYTES))) .ok()?; let mut bytes = Vec::new(); file.take(TAIL_BYTES).read_to_end(&mut bytes).ok()?; Some(String::from_utf8_lossy(&bytes).into_owned())}
/// The newest model identifier among the last [`MAX_ROWS_SCANNED`] rows.fn model_in_tail(tail: &str) -> Option<String> { tail.lines() .rev() .take(MAX_ROWS_SCANNED) .find_map(model_in_row)}
/// The model identifier one transcript row declares, if it is an assistant row.////// Every step is a question that may be answered "no" rather than a shape this/// asserts: an unparseable row, a row of another type, a row whose message has/// no model, and a model that is empty or longer than the record can hold all/// return `None` and are all ordinary.fn model_in_row(row: &str) -> Option<String> { let row = row.trim(); if row.is_empty() { return None; } let value: serde_json::Value = serde_json::from_str(row).ok()?; if value.get("type")?.as_str()? != "assistant" { return None; } let model = value.get("message")?.get("model")?.as_str()?.trim(); if model.is_empty() || model.len() > MODEL_MAX_LENGTH { return None; } Some(model.to_string())}
#[cfg(test)]mod tests { use super::*; use std::path::PathBuf;
/// Writes `contents` to a uniquely named file and returns its path. /// /// A hand-rolled temporary file rather than a dependency: this crate has /// two, both of which the library itself needs, and one write in a test is /// not worth a third. fn transcript(name: &str, contents: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "didbot-transcript-{name}-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock") .as_nanos(), )); std::fs::create_dir_all(&dir).expect("temp dir"); let path = dir.join("transcript.jsonl"); std::fs::write(&path, contents).expect("write transcript"); path }
fn assistant(model: &str) -> String { format!(r#"{{"type":"assistant","message":{{"model":"{model}","role":"assistant"}}}}"#) }
#[test] fn reads_the_model_off_the_last_assistant_row() { let path = transcript( "plain", &format!( "{}\n{}\n{}\n", assistant("claude-sonnet-4-5"), r#"{"type":"user","message":{"role":"user"}}"#, assistant("claude-opus-5"), ), ); assert_eq!(model_for_turn(&path).as_deref(), Some("claude-opus-5")); }
#[test] fn a_missing_file_is_not_an_error() { assert!(model_for_turn(Path::new("/nonexistent/dir/transcript.jsonl")).is_none()); }
#[test] fn a_directory_in_place_of_a_transcript_is_not_an_error() { // `File::open` succeeds on a directory on Linux and the read fails. assert!(model_for_turn(&std::env::temp_dir()).is_none()); }
#[test] fn an_empty_transcript_is_not_an_error() { assert!(model_for_turn(&transcript("empty", "")).is_none()); assert!(model_for_turn(&transcript("blank", "\n\n \n")).is_none()); }
#[test] fn a_transcript_cut_off_mid_line_still_answers_from_the_row_before() { // The harness appends as it goes, so a read can land between the write // of a row and its newline. The truncated row must be skipped, not // allowed to hide the complete one under it. let path = transcript( "truncated", &format!( "{}\n{}", assistant("claude-opus-5"), r#"{"type":"assistant","message":{"mod"#, ), ); assert_eq!(model_for_turn(&path).as_deref(), Some("claude-opus-5")); }
#[test] fn a_transcript_of_nothing_but_a_partial_row_is_not_an_error() { assert!(model_for_turn(&transcript("only-partial", r#"{"type":"assis"#)).is_none()); }
#[test] fn a_transcript_with_no_assistant_rows_has_no_model() { let path = transcript( "no-assistant", "{\"type\":\"user\",\"message\":{\"role\":\"user\"}}\n\ {\"type\":\"system\",\"subtype\":\"init\"}\n", ); assert!(model_for_turn(&path).is_none()); }
#[test] fn rows_in_an_unfamiliar_shape_are_skipped_rather_than_trusted() { // Each of these is a way the format could move under us: no `type`, a // `type` that is not a string, no `message`, a `message` that is not an // object, a model of the wrong type, and an empty model. None of them // may panic and none may produce a value. for row in [ r#"{"message":{"model":"claude-opus-5"}}"#, r#"{"type":7,"message":{"model":"claude-opus-5"}}"#, r#"{"type":"assistant"}"#, r#"{"type":"assistant","message":"claude-opus-5"}"#, r#"{"type":"assistant","message":{"model":42}}"#, r#"{"type":"assistant","message":{"model":" "}}"#, r#"["assistant","claude-opus-5"]"#, "null", "not json at all", ] { assert!( model_in_row(row).is_none(), "{row} should not have produced a model" ); } }
#[test] fn a_model_longer_than_the_record_can_hold_is_dropped() { let long = "m".repeat(MODEL_MAX_LENGTH + 1); assert!(model_in_row(&assistant(&long)).is_none()); let at_limit = "m".repeat(MODEL_MAX_LENGTH); assert_eq!( model_in_row(&assistant(&at_limit)).as_deref(), Some(&*at_limit) ); }
#[test] fn only_the_tail_of_a_long_transcript_is_read() { // The model is written once at the very top and then buried under more // than TAIL_BYTES of other rows. Answering `None` is the point: this // must never grow into a whole-file parse. let filler = format!("{}\n", r#"{"type":"user","message":{"role":"user"}}"#); let mut contents = format!("{}\n", assistant("claude-opus-5")); while contents.len() < usize::try_from(TAIL_BYTES).expect("fits") * 2 { contents.push_str(&filler); } assert!(model_for_turn(&transcript("long", &contents)).is_none()); }
#[test] fn an_assistant_row_older_than_the_scan_bound_is_not_reached() { let mut contents = format!("{}\n", assistant("claude-opus-5")); for _ in 0..MAX_ROWS_SCANNED { contents.push_str(r#"{"type":"user","message":{"role":"user"}}"#); contents.push('\n'); } assert!(model_for_turn(&transcript("deep", &contents)).is_none()); }}