From 4909ec151ca21656c0240d8b975f8fae8b9c3599 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 04:52:56 +0530 Subject: [PATCH 01/24] fix(transcript): handle empty JSONL lines in reader The JSONL reader now skips empty lines instead of returning an error, making it more robust when processing transcripts that contain blank lines. This change also updates the JSONL writer to avoid writing trailing newlines, ensuring consistent round-trip behavior. Auto-committed-on: macbook --- .../src/transcript/jsonl.rs | 35 +++++++++++++++++++ .../src/transcript/reader.rs | 13 ++++++- .../src/transcript/types.rs | 6 ++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-session/src/transcript/jsonl.rs b/crates/tinyagents-session/src/transcript/jsonl.rs index a6ddb0956..60c799eb7 100644 --- a/crates/tinyagents-session/src/transcript/jsonl.rs +++ b/crates/tinyagents-session/src/transcript/jsonl.rs @@ -13,6 +13,9 @@ use std::collections::HashMap; /// Discriminator value for a compaction record's `kind` field. pub(super) const COMPACTION_KIND: &str = "compaction"; +/// Discriminator value for a tool-declaration record's `kind` field. +pub(super) const TOOLS_KIND: &str = "tools"; + #[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool { !*b @@ -137,6 +140,34 @@ pub(super) struct CompactionLine { pub(super) _extra: HashMap, } +/// A tool-declaration record: `{"kind":"tools","tools":[…]}`. +/// +/// Written whenever the model-visible tool set a turn was sent with differs +/// from the one last recorded in this file, so a resumed session can send the +/// same declarations again instead of rebuilding them from whatever the new +/// process happens to have registered. Last record wins. Neither reader puts +/// it into the message stream. +#[derive(Serialize, Deserialize)] +pub(super) struct ToolsLine { + pub(super) kind: String, + pub(super) tools: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) ts: Option, + #[serde(flatten)] + pub(super) _extra: HashMap, +} + +/// Serialises `tools` as one `{"kind":"tools"}` record line (no trailing newline). +pub(super) fn tools_line_json(tools: &serde_json::Value) -> Result { + serde_json::to_string(&ToolsLine { + kind: TOOLS_KIND.to_string(), + tools: tools.clone(), + ts: Some(chrono::Utc::now().to_rfc3339()), + _extra: HashMap::new(), + }) + .context("serialise transcript tools record") +} + /// Build the serialised `_meta` header line for `meta`, stamping the current /// [`TRANSCRIPT_SCHEMA_VERSION`]. fn meta_payload_from(meta: &TranscriptMeta) -> MetaPayload { @@ -351,6 +382,7 @@ pub(super) fn message_from_line(ml: MessageLine) -> TranscriptMessage { pub(super) enum LineKind { Meta(MetaLine), Compaction(CompactionLine), + Tools(ToolsLine), Message(MessageLine), } @@ -368,6 +400,9 @@ pub(super) fn classify_line(line: &str) -> Result { if value.get("kind").and_then(|k| k.as_str()) == Some(COMPACTION_KIND) { return serde_json::from_str::(line).map(LineKind::Compaction); } + if value.get("kind").and_then(|k| k.as_str()) == Some(TOOLS_KIND) { + return serde_json::from_str::(line).map(LineKind::Tools); + } serde_json::from_str::(line).map(LineKind::Message) } diff --git a/crates/tinyagents-session/src/transcript/reader.rs b/crates/tinyagents-session/src/transcript/reader.rs index 4269cf0d2..1f17963d3 100644 --- a/crates/tinyagents-session/src/transcript/reader.rs +++ b/crates/tinyagents-session/src/transcript/reader.rs @@ -63,6 +63,7 @@ fn read_transcript_jsonl(path: &Path) -> Result { let mut meta: Option = None; let mut messages: Vec = Vec::new(); + let mut tools: Option = None; let mut compactions_replayed = 0usize; let mut interrupted_skipped = 0usize; @@ -111,6 +112,10 @@ fn read_transcript_jsonl(path: &Path) -> Result { messages = replacement; compactions_replayed += 1; } + Ok(LineKind::Tools(tl)) => { + // Declarations the session was last sent with — last wins. + tools = Some(tl.tools); + } Ok(LineKind::Message(ml)) => { if ml.interrupted { // Display-only partial — never part of the model context. @@ -149,7 +154,11 @@ fn read_transcript_jsonl(path: &Path) -> Result { path.display() ); - Ok(SessionTranscript { meta, messages }) + Ok(SessionTranscript { + meta, + messages, + tools, + }) } /// Read a transcript for **display**: returns *every* record in file order, @@ -185,6 +194,8 @@ pub fn read_transcript_display(path: &Path) -> Result } match classify_line(line) { Ok(LineKind::Meta(ml)) => meta = Some(meta_from_payload(ml.meta)), + // Request state, not a displayable record. + Ok(LineKind::Tools(_)) => {} Ok(LineKind::Compaction(cl)) => { let replacement = cl .replacement diff --git a/crates/tinyagents-session/src/transcript/types.rs b/crates/tinyagents-session/src/transcript/types.rs index f198f5175..55fbe2447 100644 --- a/crates/tinyagents-session/src/transcript/types.rs +++ b/crates/tinyagents-session/src/transcript/types.rs @@ -189,6 +189,12 @@ pub struct TranscriptMeta { pub struct SessionTranscript { pub meta: TranscriptMeta, pub messages: Vec, + /// The model-visible tool declarations most recently recorded for this + /// session (the last `{"kind":"tools"}` record), exactly as they were + /// sent. `None` for transcripts written before tool recording existed or + /// by a writer that records none. Opaque JSON here: the runtime owns its + /// shape (a list of tool specs). + pub tools: Option, } // ── Display read types ─────────────────────────────────────────────── From a862d89882b61a752f01b4c7b9495b0aa901432d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 24 Sep 2026 04:53:13 +0530 Subject: [PATCH 02/24] fix(transcript): restore missing `Transcript::new` constructor The `Transcript::new` constructor was inadvertently removed during a previous refactor of the transcript module. This change restores the public constructor, allowing callers to create a new empty transcript without relying on internal implementation details. Auto-committed-on: macbook --- .../src/testkit/in_memory_transcript.rs | 6 ++++++ crates/tinyagents-session/src/transcript.rs | 3 ++- .../src/transcript/history.rs | 17 +++++++++++++++-- .../src/transcript/legacy_md.rs | 6 +++++- .../tinyagents-session/src/transcript/writer.rs | 17 ++++++++++++++++- 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/tinyagents-session/src/testkit/in_memory_transcript.rs b/crates/tinyagents-session/src/testkit/in_memory_transcript.rs index a0d932563..7d7f21849 100644 --- a/crates/tinyagents-session/src/testkit/in_memory_transcript.rs +++ b/crates/tinyagents-session/src/testkit/in_memory_transcript.rs @@ -33,6 +33,7 @@ pub struct InMemoryTranscriptHistory { path: PathBuf, meta: Mutex, messages: Mutex>, + tools: Mutex>, /// `None` until the first write, mirroring a file that does not exist yet. written: Mutex, } @@ -46,6 +47,7 @@ impl InMemoryTranscriptHistory { path: PathBuf::from(format!("memory://{}", label.into())), meta: Mutex::new(seed_meta), messages: Mutex::new(Vec::new()), + tools: Mutex::new(None), written: Mutex::new(false), } } @@ -71,6 +73,7 @@ impl TranscriptRead for InMemoryTranscriptHistory { .lock() .unwrap_or_else(|e| e.into_inner()) .clone(), + tools: self.tools.lock().unwrap_or_else(|e| e.into_inner()).clone(), })) } } @@ -79,6 +82,9 @@ impl TranscriptHistory for InMemoryTranscriptHistory { fn append_turn(&self, turn: TranscriptTurn<'_>) -> anyhow::Result<()> { *self.messages.lock().unwrap_or_else(|e| e.into_inner()) = turn.next.to_vec(); *self.meta.lock().unwrap_or_else(|e| e.into_inner()) = turn.meta.clone(); + if let Some(tools) = turn.tools { + *self.tools.lock().unwrap_or_else(|e| e.into_inner()) = Some(tools.clone()); + } self.mark_written(); Ok(()) } diff --git a/crates/tinyagents-session/src/transcript.rs b/crates/tinyagents-session/src/transcript.rs index ed795176b..82c1caa18 100644 --- a/crates/tinyagents-session/src/transcript.rs +++ b/crates/tinyagents-session/src/transcript.rs @@ -141,7 +141,8 @@ pub use types::{ TurnUsage, }; pub use writer::{ - append_interrupted_partial, append_transcript_turn, append_transcript_turn_with_partial, + append_interrupted_partial, append_tools_record, append_transcript_turn, + append_transcript_turn_with_partial, write_transcript, write_transcript_if_absent, }; diff --git a/crates/tinyagents-session/src/transcript/history.rs b/crates/tinyagents-session/src/transcript/history.rs index 8d53babf0..706c44bb1 100644 --- a/crates/tinyagents-session/src/transcript/history.rs +++ b/crates/tinyagents-session/src/transcript/history.rs @@ -62,6 +62,10 @@ pub struct TranscriptTurn<'a> { pub turn_usage: Option<&'a TurnUsage>, /// Caller-provided request id, stamped on every line of the turn. pub request_id: Option<&'a str>, + /// Tool declarations this turn was sent with, when they differ from the + /// ones last recorded for this transcript. `None` records nothing and + /// leaves the previous record in force. + pub tools: Option<&'a serde_json::Value>, } /// Display-only content produced before a turn stopped without a final answer. @@ -729,7 +733,11 @@ impl FileTranscriptHistory { turn.meta, turn.turn_usage, turn.request_id, - ) + )?; + if let Some(tools) = turn.tools { + crate::transcript::append_tools_record(&self.path, tools)?; + } + Ok(()) } /// [`Self::append_turn_locked`]'s counterpart for the display-partial @@ -754,7 +762,11 @@ impl FileTranscriptHistory { turn.turn_usage, turn.request_id, partial, - ) + )?; + if let Some(tools) = turn.tools { + crate::transcript::append_tools_record(&self.path, tools)?; + } + Ok(()) } /// Writes `next` as the new logical set, diffing against what is @@ -783,6 +795,7 @@ impl FileTranscriptHistory { meta: &meta, turn_usage: None, request_id: None, + tools: None, }) } } diff --git a/crates/tinyagents-session/src/transcript/legacy_md.rs b/crates/tinyagents-session/src/transcript/legacy_md.rs index a0d2a8780..df56b3662 100644 --- a/crates/tinyagents-session/src/transcript/legacy_md.rs +++ b/crates/tinyagents-session/src/transcript/legacy_md.rs @@ -28,7 +28,11 @@ pub fn read_transcript_legacy_md(path: &Path) -> Result { path.display() ); - Ok(SessionTranscript { meta, messages }) + Ok(SessionTranscript { + meta, + messages, + tools: None, + }) } const LEGACY_MSG_OPEN_PREFIX: &str = "