use std::sync::Arc;
use agent_client_protocol::schema::v1::{
ContentBlock, ContentChunk, EmbeddedResourceResource, PlanEntryStatus, ToolCallStatus, ToolKind,
};
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
pub const SESSION_RESTART_TEXT: &str = "[session restarted]";
pub const SESSION_RESTART_ITEM_PREFIX: &str = "system:session-restarted:";
pub const HARNESS_TURN_TEXT: &str = "Agent continued on its own";
pub const HARNESS_TURN_ITEM_PREFIX: &str = "harness-turn:";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolSummarySourceKind {
RawInput,
RawOutput,
Title,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCallPresentation {
pub summary: String,
pub source: String,
pub source_kind: ToolSummarySourceKind,
pub tool_kind: ToolKind,
#[serde(default)]
pub summary_version: u8,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TranscriptBody {
User {
content: Vec<serde_json::Value>,
},
Agent {
chunks: Vec<serde_json::Value>,
streaming: bool,
},
Thought {
chunks: Vec<serde_json::Value>,
streaming: bool,
},
Tool {
call: serde_json::Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
terminal_outputs: Vec<TerminalOutputRecord>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
terminal_refs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
presentation: Option<Box<ToolCallPresentation>>,
},
TerminalOutput {
record: TerminalOutputRecord,
},
Plan {
plan: serde_json::Value,
},
PlanProposal {
proposal_id: String,
plan: String,
},
System {
text: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerminalOutputRecord {
pub terminal_id: String,
pub output: String,
#[serde(default, skip_serializing_if = "is_false")]
pub truncated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signal: Option<String>,
}
impl TerminalOutputRecord {
pub fn exited_cleanly(&self) -> bool {
self.exit_code == Some(0) && self.signal.is_none()
}
pub fn matches_tool_raw_result(&self, call: &serde_json::Value) -> bool {
if !matches!(
call.get("status").and_then(serde_json::Value::as_str),
Some("completed" | "failed")
) {
return false;
}
let Some(raw) = call.get("rawOutput") else {
return false;
};
let Some(exit_code) = raw
.get("exit_code")
.and_then(serde_json::Value::as_u64)
.and_then(|code| u32::try_from(code).ok())
else {
return false;
};
if self.exit_code != Some(exit_code) || self.signal.is_some() {
return false;
}
match raw.get("output") {
Some(serde_json::Value::Array(bytes)) => {
bytes.len() == self.output.len()
&& bytes
.iter()
.zip(self.output.as_bytes())
.all(|(value, byte)| value.as_u64() == Some(u64::from(*byte)))
}
Some(serde_json::Value::String(output)) => output == &self.output,
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TranscriptItem {
pub stable_id: String,
pub position: u64,
pub latest_content_event_ordinal: Option<u64>,
pub created_at_ms: i64,
pub last_changed_at_ms: i64,
pub body: TranscriptBody,
}
impl TranscriptItem {
pub fn is_session_restart(&self) -> bool {
self.stable_id.starts_with(SESSION_RESTART_ITEM_PREFIX)
}
pub fn seq(&self) -> u64 {
self.latest_content_event_ordinal.unwrap_or(self.position)
}
pub fn is_turn_start(&self) -> bool {
matches!(self.body, TranscriptBody::User { .. })
|| self.stable_id.starts_with(HARNESS_TURN_ITEM_PREFIX)
}
pub fn is_nonempty_agent_message(&self) -> bool {
let TranscriptBody::Agent { chunks, .. } = &self.body else {
return false;
};
chunks.iter().any(|chunk| {
let Some(content) = chunk.get("content") else {
return false;
};
match content.get("type").and_then(serde_json::Value::as_str) {
Some("text") => content
.get("text")
.and_then(serde_json::Value::as_str)
.is_some_and(|text| !text.trim().is_empty()),
Some(_) => true,
None => false,
}
})
}
pub fn validate(&self, through: u64) -> Result<()> {
if self.stable_id.trim().is_empty() {
bail!("materialized transcript item has an empty stable id");
}
if self.position == 0 || self.position > through {
bail!(
"materialized transcript item {:?} has invalid position {} at frontier {through}",
self.stable_id,
self.position
);
}
match (&self.body, self.latest_content_event_ordinal) {
(TranscriptBody::Agent { .. }, Some(ordinal))
if ordinal >= self.position && ordinal <= through => {}
(TranscriptBody::Agent { .. }, Some(ordinal)) => bail!(
"materialized agent message {:?} has invalid latest content ordinal {ordinal} at position {} and frontier {through}",
self.stable_id,
self.position
),
(TranscriptBody::Agent { .. }, None) => bail!(
"materialized agent message {:?} has no latest content ordinal",
self.stable_id
),
(_, Some(ordinal)) => bail!(
"non-agent transcript item {:?} has latest content ordinal {ordinal}",
self.stable_id
),
(_, None) => {}
}
if self.last_changed_at_ms < self.created_at_ms {
bail!(
"materialized transcript item {:?} changed before it was created",
self.stable_id
);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ChatRole {
User,
Agent,
Thought,
Tool,
Plan,
PlanProposal,
System,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ChatEntry {
#[serde(default)]
pub start_seq: u64,
pub seq: u64,
pub role: ChatRole,
pub text: String,
pub recorded_at_ms: Option<i64>,
pub revision: u64,
pub message_id: Option<String>,
pub tool_call_id: Option<String>,
pub tool_status: Option<ToolStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_presentation: Option<ToolCallPresentation>,
pub tool_content: Vec<String>,
pub tool_diffstats: Vec<String>,
pub tool_locations: Vec<String>,
pub plan: Vec<PlanLine>,
#[serde(default, skip_serializing_if = "is_false")]
pub leading_omitted: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub raw_only: bool,
#[serde(skip)]
pub source: TranscriptSource,
}
#[derive(Debug, Clone, Default)]
pub struct TranscriptSource(pub Option<Arc<TranscriptItem>>);
impl TranscriptSource {
pub fn is(&self, item: &Arc<TranscriptItem>) -> bool {
self.0
.as_ref()
.is_some_and(|source| Arc::ptr_eq(source, item))
}
}
impl PartialEq for TranscriptSource {
fn eq(&self, _other: &Self) -> bool {
true
}
}
impl Eq for TranscriptSource {}
impl ChatEntry {
pub fn is_session_restart(&self) -> bool {
self.source
.0
.as_ref()
.is_some_and(|item| item.is_session_restart())
|| (self.role == ChatRole::System && self.text == SESSION_RESTART_TEXT)
}
pub fn plan(seq: u64, plan: Vec<PlanLine>) -> Self {
Self {
start_seq: seq,
seq,
role: ChatRole::Plan,
text: String::new(),
recorded_at_ms: None,
revision: 0,
message_id: None,
tool_call_id: None,
tool_status: None,
tool_summary: None,
tool_presentation: None,
tool_content: Vec::new(),
tool_diffstats: Vec::new(),
tool_locations: Vec::new(),
plan,
leading_omitted: false,
raw_only: false,
source: TranscriptSource::default(),
}
}
pub fn touch(&mut self, seq: u64) {
self.seq = seq;
self.revision = self.revision.wrapping_add(1);
}
#[doc(hidden)]
pub fn bounded_for_dashboard(mut self) -> Self {
self.bound_dashboard_content();
self
}
fn bound_dashboard_content(&mut self) {
const TEXT_BYTES: usize = 64 * 1024;
const DETAIL_BYTES: usize = 2 * 1024;
const DETAIL_COUNT: usize = 8;
self.leading_omitted |= truncate_string_start(&mut self.text, TEXT_BYTES);
for values in [
&mut self.tool_content,
&mut self.tool_diffstats,
&mut self.tool_locations,
] {
values.truncate(DETAIL_COUNT);
for value in values {
truncate_string_start(value, DETAIL_BYTES);
}
}
if let Some(summary) = &mut self.tool_summary {
truncate_string_start(summary, DETAIL_BYTES);
}
if let Some(presentation) = &mut self.tool_presentation {
truncate_string_start(&mut presentation.summary, DETAIL_BYTES);
truncate_string_start(&mut presentation.source, TEXT_BYTES);
}
self.plan.truncate(DETAIL_COUNT);
for line in &mut self.plan {
truncate_string_start(&mut line.text, DETAIL_BYTES);
}
}
pub fn with_recorded_at(mut self, recorded_at_ms: Option<i64>) -> Self {
self.recorded_at_ms = recorded_at_ms;
self
}
}
impl ChatEntry {
pub fn plain(seq: u64, role: ChatRole, text: impl Into<String>) -> Self {
Self {
start_seq: seq,
seq,
role,
text: sanitize_terminal_text(&text.into()),
recorded_at_ms: None,
revision: 0,
message_id: None,
tool_call_id: None,
tool_status: None,
tool_summary: None,
tool_presentation: None,
tool_content: Vec::new(),
tool_diffstats: Vec::new(),
tool_locations: Vec::new(),
plan: Vec::new(),
leading_omitted: false,
raw_only: false,
source: TranscriptSource::default(),
}
}
pub fn tool(
seq: u64,
title: impl Into<String>,
tool_call_id: Option<String>,
tool_status: ToolStatus,
) -> Self {
Self {
start_seq: seq,
seq,
role: ChatRole::Tool,
text: sanitize_terminal_text(&title.into()),
recorded_at_ms: None,
revision: 0,
message_id: None,
tool_call_id,
tool_status: Some(tool_status),
tool_summary: None,
tool_presentation: None,
tool_content: Vec::new(),
tool_diffstats: Vec::new(),
tool_locations: Vec::new(),
plan: Vec::new(),
leading_omitted: false,
raw_only: false,
source: TranscriptSource::default(),
}
}
}
pub(crate) fn is_false(value: &bool) -> bool {
!*value
}
pub fn plan_status(status: &PlanEntryStatus) -> PlanStatus {
match status {
PlanEntryStatus::InProgress => PlanStatus::Running,
PlanEntryStatus::Completed => PlanStatus::Completed,
_ => PlanStatus::Pending,
}
}
pub fn sanitize_terminal_text(text: &str) -> String {
let mut sanitized = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
while consume_escape_body(&mut chars) {}
} else if ch == '\r' {
if chars.peek() != Some(&'\n') {
sanitized.push('\n');
}
} else if matches!(ch, '\n' | '\t') || !ch.is_control() {
sanitized.push(ch);
}
}
sanitized
}
fn consume_escape_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
match chars.next() {
Some('[') => {
let _ = chars.find(|ch| ('@'..='~').contains(ch));
false
}
Some(']' | 'P' | 'X' | '^' | '_') => consume_string_body(chars),
Some('(' | ')' | '*' | '+' | '-' | '.' | '/' | '#' | '%' | ' ') => {
chars.next();
false
}
_ => false,
}
}
fn consume_string_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
while let Some(&ch) = chars.peek() {
match ch {
'\n' | '\r' | '\x18' | '\x1a' => return false,
'\x07' => {
chars.next();
return false;
}
'\x1b' => {
chars.next();
return true;
}
_ => {
chars.next();
}
}
}
false
}
pub fn materialized_content_text(content: &[serde_json::Value]) -> String {
let text = content
.iter()
.map(materialized_value_text)
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n");
crate::relay::strip_hidden_prompt_context(&text).to_owned()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TranscriptRole {
User,
Agent,
Thought,
Tool,
Terminal,
Plan,
PlanProposal,
System,
}
impl TranscriptRole {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Agent => "agent",
Self::Thought => "thought",
Self::Tool => "tool",
Self::Terminal => "terminal",
Self::Plan => "plan",
Self::PlanProposal => "plan_proposal",
Self::System => "system",
}
}
pub fn storage_kind(self) -> &'static str {
match self {
Self::Terminal => "terminal_output",
other => other.as_str(),
}
}
}
pub fn transcript_item_role(body: &TranscriptBody) -> &'static str {
let role = match body {
TranscriptBody::User { .. } => TranscriptRole::User,
TranscriptBody::Agent { .. } => TranscriptRole::Agent,
TranscriptBody::Thought { .. } => TranscriptRole::Thought,
TranscriptBody::Tool { .. } => TranscriptRole::Tool,
TranscriptBody::TerminalOutput { .. } => TranscriptRole::Terminal,
TranscriptBody::Plan { .. } => TranscriptRole::Plan,
TranscriptBody::PlanProposal { .. } => TranscriptRole::PlanProposal,
TranscriptBody::System { .. } => TranscriptRole::System,
};
role.as_str()
}
pub fn materialized_chunks_text(chunks: &[serde_json::Value]) -> String {
chunks
.iter()
.filter_map(|value| match ContentChunk::deserialize(value) {
Ok(chunk) => Some(chunk),
Err(error) => {
tracing::warn!(%error, "could not decode a stored content chunk");
None
}
})
.filter_map(|chunk| content_block_text(&chunk.content))
.map(|text| sanitize_terminal_text(&text))
.collect::<Vec<_>>()
.join("")
}
fn materialized_value_text(value: &serde_json::Value) -> String {
if let Ok(block) = ContentBlock::deserialize(value)
&& let Some(text) = content_block_text(&block)
{
return sanitize_terminal_text(&text);
}
if let Some(text) = value.as_str() {
return sanitize_terminal_text(text);
}
sanitize_terminal_text(&serde_json::to_string(value).unwrap_or_else(|_| "[content]".into()))
}
pub fn tool_status(status: &ToolCallStatus) -> ToolStatus {
match status {
ToolCallStatus::InProgress => ToolStatus::Running,
ToolCallStatus::Completed => ToolStatus::Completed,
ToolCallStatus::Failed => ToolStatus::Failed,
_ => ToolStatus::Pending,
}
}
pub fn content_block_text(content: &ContentBlock) -> Option<String> {
match content {
ContentBlock::Text(text) => Some(text.text.clone()),
ContentBlock::Image(_) => Some("[image]".into()),
ContentBlock::Audio(_) => Some("[audio]".into()),
ContentBlock::ResourceLink(link) => Some(format!("[{}]({})", link.name, link.uri)),
ContentBlock::Resource(resource) => Some(match &resource.resource {
EmbeddedResourceResource::TextResourceContents(resource) => resource.text.clone(),
EmbeddedResourceResource::BlobResourceContents(resource) => {
format!("[embedded resource: {}]", resource.uri)
}
_ => "[embedded resource]".into(),
}),
_ => None,
}
}
fn truncate_string_start(value: &mut String, maximum_bytes: usize) -> bool {
if value.len() <= maximum_bytes {
return false;
}
let mut start = value.len() - maximum_bytes;
while !value.is_char_boundary(start) {
start += 1;
}
value.drain(..start);
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ToolStatus {
Pending,
Running,
Completed,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PlanStatus {
Pending,
Running,
Completed,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PlanLine {
pub text: String,
pub status: PlanStatus,
}