use aion_core::{ActivityEvent, ActivityEventKind, ProgressDetail, StopKind};
use aion_store::StoreError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TranscriptBounds {
pub max_event_bytes: usize,
pub max_stream_events: u64,
}
impl Default for TranscriptBounds {
fn default() -> Self {
Self {
max_event_bytes: crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES,
max_stream_events: crate::config::DEFAULT_OBSERVABILITY_MAX_STREAM_EVENTS,
}
}
}
fn serialized_len(event: &ActivityEvent) -> Result<usize, StoreError> {
Ok(serde_json::to_vec(event)
.map_err(|error| StoreError::Serialization(error.to_string()))?
.len())
}
const fn kind_tag(kind: &ActivityEventKind) -> &'static str {
match kind {
ActivityEventKind::Message { .. } => "message",
ActivityEventKind::ToolCall { .. } => "tool_call",
ActivityEventKind::ToolResult { .. } => "tool_result",
ActivityEventKind::Progress { .. } => "progress",
ActivityEventKind::Stop { .. } => "stop",
ActivityEventKind::Raw { .. } => "raw",
ActivityEventKind::Delta { .. } => "delta",
}
}
pub(crate) fn bound_event(
event: &ActivityEvent,
max_event_bytes: usize,
) -> Result<ActivityEvent, StoreError> {
let size = serialized_len(event)?;
if size <= max_event_bytes {
return Ok(event.clone());
}
if matches!(event.kind, ActivityEventKind::Delta { .. }) {
return Ok(event.clone());
}
let mut reduced = event.clone();
if let Some(kind) = reduce_kind(&event.kind, size, max_event_bytes) {
reduced.kind = kind;
if serialized_len(&reduced)? <= max_event_bytes {
return Ok(reduced);
}
}
reduced.kind = ActivityEventKind::Progress {
detail: ProgressDetail::Note {
text: format!(
"event truncated: {} of {size} bytes exceeded observability.max_event_bytes={max_event_bytes}",
kind_tag(&event.kind)
),
},
};
Ok(reduced)
}
fn reduce_kind(
kind: &ActivityEventKind,
size: usize,
max_event_bytes: usize,
) -> Option<ActivityEventKind> {
match kind {
ActivityEventKind::Message { role, text } => Some(ActivityEventKind::Message {
role: *role,
text: truncate_text(text, size, max_event_bytes),
}),
ActivityEventKind::Progress {
detail: ProgressDetail::Note { text },
} => Some(ActivityEventKind::Progress {
detail: ProgressDetail::Note {
text: truncate_text(text, size, max_event_bytes),
},
}),
ActivityEventKind::Stop {
reason: StopKind::Error { message },
} => Some(ActivityEventKind::Stop {
reason: StopKind::Error {
message: truncate_text(message, size, max_event_bytes),
},
}),
ActivityEventKind::Stop {
reason: StopKind::Other { reason },
} => Some(ActivityEventKind::Stop {
reason: StopKind::Other {
reason: truncate_text(reason, size, max_event_bytes),
},
}),
ActivityEventKind::ToolCall {
tool,
call_id,
input,
} => Some(ActivityEventKind::ToolCall {
tool: tool.clone(),
call_id: call_id.clone(),
input: truncation_stub(input),
}),
ActivityEventKind::ToolResult {
call_id,
output,
is_error,
} => Some(ActivityEventKind::ToolResult {
call_id: call_id.clone(),
output: truncation_stub(output),
is_error: *is_error,
}),
ActivityEventKind::Raw { source, value } => Some(ActivityEventKind::Raw {
source: source.clone(),
value: truncation_stub(value),
}),
ActivityEventKind::Progress { .. }
| ActivityEventKind::Stop { .. }
| ActivityEventKind::Delta { .. } => None,
}
}
fn truncation_stub(original: &serde_json::Value) -> serde_json::Value {
let original_bytes = serde_json::to_vec(original).map_or(0, |bytes| bytes.len());
serde_json::json!({
"truncated": true,
"original_bytes": original_bytes,
"reason": "observability.max_event_bytes",
})
}
fn truncate_text(text: &str, size: usize, max_event_bytes: usize) -> String {
let overhead = size.saturating_sub(text.len());
let budget = max_event_bytes.saturating_sub(overhead);
let provisional = truncation_marker(text.len());
let keep_budget = budget.saturating_sub(provisional.len());
let kept = truncate_on_char_boundary(text, keep_budget);
let omitted = text.len().saturating_sub(kept.len());
format!("{kept}{}", truncation_marker(omitted))
}
fn truncation_marker(omitted: usize) -> String {
format!(" …[truncated {omitted} bytes by observability.max_event_bytes]")
}
fn truncate_on_char_boundary(text: &str, max_bytes: usize) -> &str {
if text.len() <= max_bytes {
return text;
}
let mut end = 0;
for (index, character) in text.char_indices() {
let next = index + character.len_utf8();
if next > max_bytes {
break;
}
end = next;
}
text.get(..end).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use aion_core::{ActivityId, MessageRole, WorkflowId};
use chrono::Utc;
use uuid::Uuid;
use super::*;
fn envelope(kind: ActivityEventKind) -> ActivityEvent {
ActivityEvent {
workflow_id: WorkflowId::new(Uuid::from_u128(1)),
activity_id: ActivityId::from_sequence_position(3),
attempt: 0,
agent_id: Uuid::from_u128(9),
agent_role: "orchestrator".to_owned(),
emitted_at: Utc::now(),
worker_seq: 1,
store_seq: None,
ephemeral: false,
kind,
}
}
fn message(text: &str) -> ActivityEvent {
envelope(ActivityEventKind::Message {
role: MessageRole::Assistant,
text: text.to_owned(),
})
}
#[test]
fn undersized_event_passes_through_unchanged() -> Result<(), StoreError> {
let event = message("small");
let bounded = bound_event(&event, 64 * 1024)?;
assert_eq!(bounded, event);
Ok(())
}
#[test]
fn message_text_truncates_on_a_char_boundary_with_marker() -> Result<(), StoreError> {
let text: String = "気".repeat(4_000);
let event = message(&text);
let bounded = bound_event(&event, 512)?;
let ActivityEventKind::Message {
text: bounded_text, ..
} = &bounded.kind
else {
return Err(StoreError::Backend("expected a Message kind".to_owned()));
};
assert!(
bounded_text.contains("…[truncated"),
"the marker names the truncation: {bounded_text}"
);
assert!(
bounded_text.contains("observability.max_event_bytes"),
"the marker names the operator knob"
);
let kept = bounded_text
.split(" …[truncated")
.next()
.unwrap_or_default();
assert_eq!(kept.len() % 3, 0, "the cut lands on a char boundary");
assert!(serialized_len(&bounded)? <= 512);
Ok(())
}
#[test]
fn tool_result_output_is_replaced_with_truncation_stub() -> Result<(), StoreError> {
let event = envelope(ActivityEventKind::ToolResult {
call_id: "call-1".to_owned(),
output: serde_json::json!({ "blob": "x".repeat(10_000) }),
is_error: false,
});
let bounded = bound_event(&event, 512)?;
let ActivityEventKind::ToolResult {
call_id,
output,
is_error,
} = &bounded.kind
else {
return Err(StoreError::Backend("expected a ToolResult kind".to_owned()));
};
assert_eq!(call_id, "call-1");
assert!(!is_error);
assert_eq!(output["truncated"], serde_json::json!(true));
assert_eq!(
output["reason"],
serde_json::json!("observability.max_event_bytes")
);
assert!(output["original_bytes"].as_u64().unwrap_or(0) > 10_000);
assert!(serialized_len(&bounded)? <= 512);
Ok(())
}
#[test]
fn raw_value_is_replaced_with_truncation_stub() -> Result<(), StoreError> {
let event = envelope(ActivityEventKind::Raw {
source: "unknown-harness".to_owned(),
value: serde_json::json!({ "blob": "y".repeat(10_000) }),
});
let bounded = bound_event(&event, 512)?;
let ActivityEventKind::Raw { source, value } = &bounded.kind else {
return Err(StoreError::Backend("expected a Raw kind".to_owned()));
};
assert_eq!(source, "unknown-harness");
assert_eq!(value["truncated"], serde_json::json!(true));
assert!(serialized_len(&bounded)? <= 512);
Ok(())
}
#[test]
fn pathological_event_falls_back_to_note() -> Result<(), StoreError> {
let mut event = envelope(ActivityEventKind::Stop {
reason: StopKind::EndTurn,
});
event.agent_role = "r".repeat(2_000);
let bounded = bound_event(&event, 2_100)?;
let ActivityEventKind::Progress {
detail: ProgressDetail::Note { text },
} = &bounded.kind
else {
return Err(StoreError::Backend("expected the note fallback".to_owned()));
};
assert!(text.contains("event truncated: stop"));
assert!(text.contains("observability.max_event_bytes=2100"));
Ok(())
}
#[test]
fn every_kind_bounds_within_the_ceiling() -> Result<(), StoreError> {
let big = "z".repeat(50_000);
let ceiling = 4_096;
let kinds = vec![
ActivityEventKind::Message {
role: MessageRole::User,
text: big.clone(),
},
ActivityEventKind::ToolCall {
tool: "read_file".to_owned(),
call_id: "call-1".to_owned(),
input: serde_json::json!({ "blob": big.clone() }),
},
ActivityEventKind::ToolResult {
call_id: "call-1".to_owned(),
output: serde_json::json!({ "blob": big.clone() }),
is_error: true,
},
ActivityEventKind::Progress {
detail: ProgressDetail::Note { text: big.clone() },
},
ActivityEventKind::Progress {
detail: ProgressDetail::UsageEstimate {
input_tokens: Some(1),
output_tokens: None,
},
},
ActivityEventKind::Stop {
reason: StopKind::Error {
message: big.clone(),
},
},
ActivityEventKind::Stop {
reason: StopKind::Other {
reason: big.clone(),
},
},
ActivityEventKind::Stop {
reason: StopKind::EndTurn,
},
ActivityEventKind::Raw {
source: "src".to_owned(),
value: serde_json::json!([big.clone()]),
},
];
for kind in kinds {
let bounded = bound_event(&envelope(kind), ceiling)?;
assert!(
serialized_len(&bounded)? <= ceiling,
"every bounded kind re-serializes within the ceiling: {bounded:?}"
);
}
Ok(())
}
}