use std::{
collections::BTreeMap,
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use crate::{ContentBlock, Message, Role};
static NEXT_ENTRY_SUFFIX: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct EntryId(String);
impl EntryId {
pub fn new() -> Self {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let suffix = NEXT_ENTRY_SUFFIX.fetch_add(1, Ordering::Relaxed);
Self(format!("entry-{stamp:x}-{suffix:x}"))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for EntryId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for EntryId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum BranchError {
#[error("no entry '{0}' anywhere in the transcript")]
UnknownEntry(EntryId),
#[error("entry '{entry}' has a broken parent chain: '{missing}' is not in the transcript")]
BrokenChain { entry: EntryId, missing: EntryId },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(from = "AgentTranscriptWire")]
pub struct AgentTranscript {
items: Vec<TranscriptItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
archive: Vec<TranscriptItem>,
}
#[derive(Deserialize)]
struct AgentTranscriptWire {
#[serde(default)]
items: Vec<TranscriptItem>,
#[serde(default)]
archive: Vec<TranscriptItem>,
}
impl From<AgentTranscriptWire> for AgentTranscript {
fn from(wire: AgentTranscriptWire) -> Self {
let mut transcript = Self {
items: wire.items,
archive: wire.archive,
};
transcript.link_active_path();
transcript
}
}
impl AgentTranscript {
pub fn new(items: Vec<TranscriptItem>) -> Self {
let mut transcript = Self {
items,
archive: Vec::new(),
};
transcript.link_active_path();
transcript
}
pub fn from_messages(messages: Vec<Message>) -> Self {
Self::new(
messages
.into_iter()
.map(transcript_item_from_message)
.collect(),
)
}
fn link_active_path(&mut self) {
for index in 1..self.items.len() {
if self.items[index].parent_id.is_none() {
self.items[index].parent_id = Some(self.items[index - 1].id.clone());
}
}
}
pub fn leaf(&self) -> Option<&EntryId> {
self.items.last().map(|item| &item.id)
}
pub fn archived(&self) -> &[TranscriptItem] {
&self.archive
}
pub fn entry(&self, id: &EntryId) -> Option<&TranscriptItem> {
self.items
.iter()
.chain(self.archive.iter())
.find(|item| &item.id == id)
}
pub fn children(&self, id: &EntryId) -> Vec<&TranscriptItem> {
self.items
.iter()
.chain(self.archive.iter())
.filter(|item| item.parent_id.as_ref() == Some(id))
.collect()
}
pub fn branch_from(&mut self, id: &EntryId) -> Result<usize, BranchError> {
if let Some(position) = self.items.iter().position(|item| &item.id == id) {
let abandoned = self.items.split_off(position + 1);
let count = abandoned.len();
self.archive.extend(abandoned);
return Ok(count);
}
if !self.archive.iter().any(|item| &item.id == id) {
return Err(BranchError::UnknownEntry(id.clone()));
}
let path = self.path_to(id)?;
let restored: Vec<TranscriptItem> = path
.iter()
.map(|id| {
self.take_anywhere(id)
.expect("path_to only names entries that exist")
})
.collect();
let count = self.items.len();
let previous = std::mem::replace(&mut self.items, restored);
self.archive.extend(previous);
Ok(count)
}
fn path_to(&self, id: &EntryId) -> Result<Vec<EntryId>, BranchError> {
let mut path = Vec::new();
let mut cursor = Some(id.clone());
while let Some(current) = cursor {
let Some(item) = self.entry(¤t) else {
return Err(BranchError::BrokenChain {
entry: id.clone(),
missing: current,
});
};
cursor = item.parent_id.clone();
path.push(current);
if path.len() > self.items.len() + self.archive.len() {
return Err(BranchError::BrokenChain {
entry: id.clone(),
missing: id.clone(),
});
}
}
path.reverse();
Ok(path)
}
fn take_anywhere(&mut self, id: &EntryId) -> Option<TranscriptItem> {
if let Some(index) = self.items.iter().position(|item| &item.id == id) {
return Some(self.items.remove(index));
}
let index = self.archive.iter().position(|item| &item.id == id)?;
Some(self.archive.remove(index))
}
pub fn items(&self) -> &[TranscriptItem] {
&self.items
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn push(&mut self, mut item: TranscriptItem) {
item.parent_id = self.leaf().cloned();
self.items.push(item);
}
pub fn to_messages(&self) -> Vec<Message> {
self.items
.iter()
.filter_map(TranscriptItem::project_message)
.collect()
}
pub fn projected_messages_from(&self, start: usize) -> Vec<Message> {
self.items
.iter()
.skip(start)
.filter_map(TranscriptItem::project_message)
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TranscriptItem {
#[serde(default)]
pub id: EntryId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<EntryId>,
pub kind: TranscriptKind,
pub message: Option<Message>,
#[serde(default, skip_serializing_if = "Option::is_none")]
details: Option<BTreeMap<String, Value>>,
}
impl TranscriptItem {
pub fn user_turn(message: Message) -> Self {
Self {
id: EntryId::new(),
parent_id: None,
kind: TranscriptKind::UserTurn,
message: Some(message),
details: None,
}
}
pub fn assistant_turn(message: Message) -> Self {
Self {
id: EntryId::new(),
parent_id: None,
kind: TranscriptKind::AssistantTurn,
message: Some(message),
details: None,
}
}
pub fn tool_exchange(message: Message, tool_use_id: Option<String>, is_error: bool) -> Self {
Self {
id: EntryId::new(),
parent_id: None,
kind: TranscriptKind::ToolExchange {
tool_use_id,
is_error,
},
message: Some(message),
details: None,
}
}
pub fn canonical_context(message: Message) -> Self {
Self {
id: EntryId::new(),
parent_id: None,
kind: TranscriptKind::CanonicalContext,
message: Some(message),
details: None,
}
}
pub fn delegation_request(
message: Message,
delegation: DelegationArtifact,
edge: Option<DelegationEdge>,
) -> Self {
Self {
id: EntryId::new(),
parent_id: None,
kind: TranscriptKind::DelegationRequest { delegation, edge },
message: Some(message),
details: None,
}
}
pub fn delegation_result(
message: Message,
delegation: DelegationArtifact,
edge: Option<DelegationEdge>,
) -> Self {
Self {
id: EntryId::new(),
parent_id: None,
kind: TranscriptKind::DelegationResult { delegation, edge },
message: Some(message),
details: None,
}
}
pub fn compaction_summary(summary: CompactionSummary) -> Self {
Self {
message: Some(Message::user(ContentBlock::text(
summary.render_for_handoff(),
))),
id: EntryId::new(),
parent_id: None,
kind: TranscriptKind::CompactionSummary { summary },
details: None,
}
}
pub fn with_details(mut self, details: BTreeMap<String, Value>) -> Self {
if !details.is_empty() {
self.details = Some(details);
}
self
}
pub fn details(&self) -> Option<&BTreeMap<String, Value>> {
self.details.as_ref()
}
pub fn detail(&self, tool_use_id: &str) -> Option<&Value> {
self.details.as_ref()?.get(tool_use_id)
}
pub fn project_message(&self) -> Option<Message> {
self.message.clone()
}
pub fn is_real_user_turn(&self) -> bool {
matches!(self.kind, TranscriptKind::UserTurn)
}
pub fn is_delegation_result(&self) -> bool {
matches!(self.kind, TranscriptKind::DelegationResult { .. })
}
pub fn text(&self) -> String {
self.message.as_ref().map(Message::text).unwrap_or_default()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TranscriptKind {
UserTurn,
AssistantTurn,
ToolExchange {
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_use_id: Option<String>,
is_error: bool,
},
CanonicalContext,
MemoryRecall,
DelegationRequest {
delegation: DelegationArtifact,
#[serde(default, skip_serializing_if = "Option::is_none")]
edge: Option<DelegationEdge>,
},
DelegationResult {
delegation: DelegationArtifact,
#[serde(default, skip_serializing_if = "Option::is_none")]
edge: Option<DelegationEdge>,
},
CompactionSummary {
summary: CompactionSummary,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DelegationKind {
Subagent,
Teammate,
Parent,
Child,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DelegationStatus {
Requested,
Finished,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DelegationEdge {
pub kind: DelegationKind,
pub local_agent_id: String,
pub remote_agent_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DelegationArtifact {
pub kind: DelegationKind,
pub agent_id: String,
pub agent_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
pub status: DelegationStatus,
pub task_summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_summary: Option<String>,
#[serde(default)]
pub artifacts: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CompactionSummary {
pub goal: String,
pub progress: String,
#[serde(default)]
pub decisions: Vec<String>,
#[serde(default)]
pub constraints: Vec<String>,
#[serde(default)]
pub delegated_work: Vec<String>,
#[serde(default)]
pub artifacts: Vec<String>,
#[serde(default)]
pub open_questions: Vec<String>,
#[serde(default)]
pub next_steps: Vec<String>,
#[serde(default)]
pub files_touched: Vec<String>,
}
impl CompactionSummary {
pub fn render_for_handoff(&self) -> String {
let mut lines = vec![
"[Compaction summary]".to_string(),
format!("Goal: {}", fallback_text(&self.goal)),
format!("Progress: {}", fallback_text(&self.progress)),
];
append_list(&mut lines, "Decisions", &self.decisions);
append_list(&mut lines, "Constraints", &self.constraints);
append_list(&mut lines, "Delegated work", &self.delegated_work);
append_list(&mut lines, "Artifacts", &self.artifacts);
append_list(&mut lines, "Open questions", &self.open_questions);
append_list(&mut lines, "Next steps", &self.next_steps);
append_list(&mut lines, "Files touched", &self.files_touched);
lines.join("\n")
}
pub fn from_fallback_text(text: String) -> Self {
Self {
progress: text,
next_steps: vec![
"Review the preserved transcript tail and continue from there.".to_string(),
],
..Self::default()
}
}
}
pub(crate) fn transcript_item_from_message(message: Message) -> TranscriptItem {
match message.role {
Role::Assistant => TranscriptItem::assistant_turn(message),
Role::User => {
if let Some((tool_use_id, is_error)) =
message.content.first().and_then(|block| match block {
ContentBlock::ToolResult {
tool_use_id,
is_error,
..
} => Some((tool_use_id.clone(), *is_error)),
_ => None,
})
{
TranscriptItem::tool_exchange(message, Some(tool_use_id), is_error)
} else {
TranscriptItem::user_turn(message)
}
}
Role::Unknown(_) => TranscriptItem::user_turn(message),
}
}
fn append_list(lines: &mut Vec<String>, label: &str, items: &[String]) {
if items.is_empty() {
return;
}
lines.push(format!("{label}:"));
for item in items {
lines.push(format!("- {item}"));
}
}
fn fallback_text(text: &str) -> &str {
if text.trim().is_empty() {
"(none)"
} else {
text
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn item_without_details_serializes_and_deserializes_as_old_format() {
let item = TranscriptItem::user_turn(Message::user(ContentBlock::text("hello")));
let json = serde_json::to_string(&item).expect("serialize");
assert!(
!json.contains("details"),
"a details-free item must serialize identically to pre-M3 transcripts, got: {json}"
);
let reloaded: TranscriptItem = serde_json::from_str(&json).expect("deserialize");
assert_eq!(reloaded.details(), None);
assert_eq!(reloaded, item);
}
#[test]
fn details_round_trip_through_json_keyed_by_tool_use_id() {
let mut details = BTreeMap::new();
details.insert("call-1".to_string(), json!({ "secret": "shh" }));
let item = TranscriptItem::tool_exchange(
Message::user(ContentBlock::text("result")),
Some("call-1".to_string()),
false,
)
.with_details(details.clone());
let json = serde_json::to_string(&item).expect("serialize");
let reloaded: TranscriptItem = serde_json::from_str(&json).expect("deserialize");
assert_eq!(reloaded.details(), Some(&details));
assert_eq!(reloaded.detail("call-1"), Some(&json!({ "secret": "shh" })));
assert_eq!(reloaded.detail("call-2"), None);
}
#[test]
fn with_details_is_a_no_op_for_an_empty_map() {
let item = TranscriptItem::user_turn(Message::user(ContentBlock::text("hello")))
.with_details(BTreeMap::new());
assert_eq!(item.details(), None);
}
#[test]
fn to_messages_projection_never_carries_details() {
let mut details = BTreeMap::new();
details.insert("call-1".to_string(), json!({ "secret": "shh" }));
let transcript = AgentTranscript::new(vec![
TranscriptItem::user_turn(Message::user(ContentBlock::text("go"))),
TranscriptItem::assistant_turn(Message::assistant(ContentBlock::ToolUse {
id: "call-1".to_string(),
name: "structured_details_tool".to_string(),
input: json!({}),
})),
TranscriptItem::tool_exchange(
Message::user(ContentBlock::ToolResult {
tool_use_id: "call-1".to_string(),
content: crate::tool::ToolResultContent::Structured(json!({ "answer": 42 })),
is_error: false,
}),
Some("call-1".to_string()),
false,
)
.with_details(details),
]);
let projected = serde_json::to_string(&transcript.to_messages()).expect("serialize");
assert!(projected.contains("answer"), "content must still project");
assert!(!projected.contains("secret"));
assert!(!projected.contains("shh"));
}
fn numbered(count: usize) -> AgentTranscript {
let mut transcript = AgentTranscript::default();
for index in 0..count {
transcript.push(TranscriptItem::user_turn(Message::user(
ContentBlock::text(index.to_string()),
)));
}
transcript
}
fn path(transcript: &AgentTranscript) -> Vec<String> {
transcript
.items()
.iter()
.filter_map(|item| item.message.as_ref())
.map(|message| message.text())
.collect()
}
#[test]
fn branching_back_shortens_the_active_path() {
let mut transcript = numbered(4);
let second = transcript.items()[1].id.clone();
let moved = transcript.branch_from(&second).expect("branches");
assert_eq!(moved, 2, "two entries left the path");
assert_eq!(path(&transcript), vec!["0", "1"]);
assert_eq!(transcript.archived().len(), 2);
}
#[test]
fn an_abandoned_branch_can_be_returned_to() {
let mut transcript = numbered(3);
let original_leaf = transcript.leaf().expect("a leaf").clone();
let first = transcript.items()[0].id.clone();
transcript.branch_from(&first).expect("branches away");
transcript.push(TranscriptItem::user_turn(Message::user(
ContentBlock::text("elsewhere"),
)));
assert_eq!(path(&transcript), vec!["0", "elsewhere"]);
let moved = transcript
.branch_from(&original_leaf)
.expect("returns to the abandoned branch");
assert_eq!(moved, 1, "only the entry unique to the old path left it");
assert_eq!(
path(&transcript),
vec!["0", "1", "2"],
"the original path comes back whole and in order"
);
}
#[test]
fn alternating_between_two_branches_converges() {
let mut transcript = numbered(2);
let fork = transcript.items()[0].id.clone();
let left = transcript.leaf().expect("a leaf").clone();
transcript.branch_from(&fork).expect("branches away");
transcript.push(TranscriptItem::user_turn(Message::user(
ContentBlock::text("right"),
)));
let right = transcript.leaf().expect("a leaf").clone();
let total = transcript.items().len() + transcript.archived().len();
for _ in 0..3 {
transcript.branch_from(&left).expect("goes left");
assert_eq!(path(&transcript), vec!["0", "1"]);
transcript.branch_from(&right).expect("goes right");
assert_eq!(path(&transcript), vec!["0", "right"]);
}
assert_eq!(
transcript.items().len() + transcript.archived().len(),
total,
"switching branches moves entries, never copies them"
);
}
#[test]
fn an_unknown_entry_is_still_refused() {
let mut transcript = numbered(2);
let stranger = EntryId::new();
assert_eq!(
transcript.branch_from(&stranger),
Err(BranchError::UnknownEntry(stranger))
);
}
#[test]
fn a_returned_to_branch_survives_a_round_trip_through_json() {
let mut transcript = numbered(3);
let leaf = transcript.leaf().expect("a leaf").clone();
let first = transcript.items()[0].id.clone();
transcript.branch_from(&first).expect("branches away");
transcript.push(TranscriptItem::user_turn(Message::user(
ContentBlock::text("elsewhere"),
)));
let text = serde_json::to_string(&transcript).expect("serializes");
let mut reloaded: AgentTranscript = serde_json::from_str(&text).expect("deserializes");
reloaded
.branch_from(&leaf)
.expect("a reloaded transcript can still return to its branch");
assert_eq!(path(&reloaded), vec!["0", "1", "2"]);
}
#[test]
fn a_child_of_an_abandoned_entry_is_still_reachable() {
let mut transcript = numbered(3);
let first = transcript.items()[0].id.clone();
let second = transcript.items()[1].id.clone();
transcript.branch_from(&first).expect("branches away");
let children = transcript.children(&first);
assert!(children.iter().any(|item| item.id == second));
assert!(transcript.branch_from(&second).is_ok());
}
}