use serde::{Deserialize, Serialize};
use crate::{ContentBlock, Message, Role};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AgentTranscript {
items: Vec<TranscriptItem>,
}
impl AgentTranscript {
pub fn new(items: Vec<TranscriptItem>) -> Self {
Self { items }
}
pub fn from_messages(messages: Vec<Message>) -> Self {
Self {
items: messages
.into_iter()
.map(transcript_item_from_message)
.collect(),
}
}
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, item: TranscriptItem) {
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 {
pub kind: TranscriptKind,
pub message: Option<Message>,
}
impl TranscriptItem {
pub fn user_turn(message: Message) -> Self {
Self {
kind: TranscriptKind::UserTurn,
message: Some(message),
}
}
pub fn assistant_turn(message: Message) -> Self {
Self {
kind: TranscriptKind::AssistantTurn,
message: Some(message),
}
}
pub fn tool_exchange(message: Message, tool_use_id: Option<String>, is_error: bool) -> Self {
Self {
kind: TranscriptKind::ToolExchange {
tool_use_id,
is_error,
},
message: Some(message),
}
}
pub fn canonical_context(message: Message) -> Self {
Self {
kind: TranscriptKind::CanonicalContext,
message: Some(message),
}
}
pub fn delegation_request(
message: Message,
delegation: DelegationArtifact,
edge: Option<DelegationEdge>,
) -> Self {
Self {
kind: TranscriptKind::DelegationRequest { delegation, edge },
message: Some(message),
}
}
pub fn delegation_result(
message: Message,
delegation: DelegationArtifact,
edge: Option<DelegationEdge>,
) -> Self {
Self {
kind: TranscriptKind::DelegationResult { delegation, edge },
message: Some(message),
}
}
pub fn compaction_summary(summary: CompactionSummary) -> Self {
Self {
message: Some(Message::user(ContentBlock::text(
summary.render_for_handoff(),
))),
kind: TranscriptKind::CompactionSummary { summary },
}
}
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>,
}
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);
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
}
}