pub(super) mod history;
pub(super) fn descriptor() -> serde_json::Value {
serde_json::json!({"schema_version":1,"event":"tool.card","history_field":"tool_card","snapshot_parts":1,
"card_bytes":16384,"core_bytes":2048,"cards_per_run":4096,"snapshots_per_run":16384,
"run_snapshot_bytes":33554432,"retained_card_bytes":8388608,"blocks_per_card":16,
"rows_per_block":32,"diff_lines_per_block":128,"children_per_block":32,"children_depth":8,
"string_bytes":1024,"title_bytes":256,"metadata_bytes":512,"preview_bytes":4096})
}
use crate::output::{
ActivityStatus, ToolActivityDetail,
tool_card::{
self, ChangeState, DiffApplication, OmissionReason, OmissionSubject,
PublicProtectionDecision, TextMeaning, ToolBlock, ToolCardInput, ToolCardRole, ToolOutcome,
},
};
use serde_json::{Value, json};
pub(super) const CARD_BYTES: usize = 16_384;
pub(super) const CORE_BYTES: usize = 2048;
pub(super) const CARDS_PER_RUN: usize = 4096;
pub(super) const RETAINED_BYTES: usize = 8 * 1024 * 1024;
pub(super) const OPTIONAL_BYTES: usize = 16 * 1024 * 1024;
pub(super) const OPTIONAL_SNAPSHOTS: usize = 8192;
pub(super) fn initial_card(id: &str, subagent: bool) -> Value {
json!({
"schema_version":1,"card_id":id,"activity_id":id,
"parent_activity_id":null,"parent_state":"root","task_id":null,
"tool_kind":if subagent { "subagent" } else { "other" },
"state":"running","outcome":null,"changes":"unknown","revision":0,"origin":"live",
"detail":{"availability":"unavailable","reason":"missing_source"},
"title":if subagent { "Subagent" } else { "Tool" },
"metadata":{"tool_name":null,"resource_path":null,"working_directory":null,
"public_protection":{"decision":"not_reported","summary":null}},
"blocks":[],"omissions":[{"subject":"blocks","reason":"missing_source","omitted_count":null}]
})
}
pub(super) fn outcome(status: ActivityStatus) -> Option<&'static str> {
match status {
ActivityStatus::Success => Some("success"),
ActivityStatus::Failed => Some("failure"),
ActivityStatus::Canceled => Some("cancelled"),
_ => None,
}
}
#[derive(Default)]
pub(super) struct BashNoticeState {
source_rows: Option<Value>,
limited_entries: usize,
unsupported_entries: usize,
unsupported_notices: usize,
protection_labels: [Option<&'static str>; 2],
}
pub(super) fn project_detail(
card: &mut Value,
detail: &ToolActivityDetail,
terminal: bool,
notice_loss: &mut BashNoticeState,
) {
let command = detail.params["command"]
.as_str()
.or_else(|| detail.params["cmd"].as_str());
let projected = tool_card::project_tool_card(&ToolCardInput {
tool_name: &detail.tool_name,
detail: Some(detail),
status: Some(detail.status),
interrupted: false,
bash_command: command,
bash_mode_response: false,
preview: "",
body: "",
children: &[],
children_complete: false,
});
let previous_outcome = card["outcome"].clone();
let previous_changes = card["changes"].clone();
let protection = card["metadata"]["public_protection"].clone();
let previous_notices = card["blocks"]
.as_array()
.unwrap()
.iter()
.find(|block| is_bash_notices(block))
.cloned();
let previous_metadata_omission = card["omissions"]
.as_array()
.unwrap()
.iter()
.find(|omission| omission["subject"] == "metadata")
.cloned();
card["tool_kind"] = json!(match projected.role {
ToolCardRole::Read | ToolCardRole::ViewImage => "read",
ToolCardRole::Grep
| ToolCardRole::AstGrep
| ToolCardRole::Find
| ToolCardRole::ListFiles => "search",
ToolCardRole::Write => "write",
ToolCardRole::HashEdit => "edit",
ToolCardRole::Bash => "command",
ToolCardRole::Web => "web",
ToolCardRole::CodeMode => "code_mode",
ToolCardRole::SubagentBatch => "subagent",
_ if detail.tool_name.starts_with("mcp_") => "mcp",
_ => "other",
});
card["title"] = json!(projected.title);
card["metadata"]["tool_name"] = json!(crate::output::sanitize_display_text(&detail.tool_name));
card["metadata"]["public_protection"] = json!({"decision": match projected.public_protection.decision {
PublicProtectionDecision::NotReported => "not_reported", PublicProtectionDecision::Allowed => "allowed",
PublicProtectionDecision::Blocked => "blocked", PublicProtectionDecision::ApprovalRequired => "approval_required",
PublicProtectionDecision::Denied => "denied",
}, "summary":projected.public_protection.summary});
let restored_protection = card["metadata"]["public_protection"]["decision"] == "not_reported"
|| (protection["decision"] == "blocked"
&& card["metadata"]["public_protection"]["decision"] != "denied");
if restored_protection {
card["metadata"]["public_protection"] = protection.clone();
}
card["changes"] = json!(match projected.changes {
ChangeState::NotApplicable => "not_applicable",
ChangeState::None => "none",
ChangeState::Applied => "applied",
ChangeState::Partial => "partial",
ChangeState::Unknown => "unknown",
});
if previous_changes == "partial"
|| (card["changes"] == "unknown" && previous_changes != "unknown")
|| (previous_changes == "applied" && card["changes"] != "partial")
{
card["changes"] = previous_changes;
}
if terminal && previous_outcome.is_null() {
card["outcome"] = json!(projected.outcome.map(|outcome| match outcome {
ToolOutcome::Success => "success",
ToolOutcome::Failure => "failure",
ToolOutcome::Cancelled => "cancelled",
ToolOutcome::Interrupted => "interrupted",
ToolOutcome::Unknown => "unknown",
}));
}
card["blocks"] = json!(projected.blocks.iter().map(block).collect::<Vec<_>>());
if projected.role == ToolCardRole::Bash
&& let Some(notices) = previous_notices
{
card["blocks"].as_array_mut().unwrap().insert(0, notices);
}
card["omissions"] = json!([]);
if restored_protection
&& protection["decision"] != "not_reported"
&& let Some(omission) = previous_metadata_omission
{
omit(card, "metadata", omission["reason"].as_str().unwrap(), None);
}
for omission in projected.omissions {
omit(
card,
match omission.subject {
OmissionSubject::Title => "title",
OmissionSubject::Metadata => "metadata",
OmissionSubject::Blocks => "blocks",
OmissionSubject::Rows => "rows",
OmissionSubject::Text => "text",
OmissionSubject::DiffLines => "diff_lines",
OmissionSubject::Children => "children",
OmissionSubject::Progress => "progress",
},
match omission.reason {
OmissionReason::Redacted => "redacted",
OmissionReason::MissingSource if card["origin"] == "saved" => "not_recorded",
OmissionReason::MissingSource => "missing_source",
OmissionReason::NotRecorded => "not_recorded",
OmissionReason::UnsupportedSource => "unsupported_source",
OmissionReason::SizeLimit => "size_limit",
},
omission.omitted_count,
);
}
if projected.role == ToolCardRole::Bash {
notice_loss.source_rows = card["omissions"]
.as_array()
.unwrap()
.iter()
.find(|omission| omission["subject"] == "rows")
.cloned();
merge_bash_notices(card, detail, notice_loss);
}
if detail.applied_diff.is_some()
&& !card["blocks"]
.as_array()
.unwrap()
.iter()
.any(|block| block["type"] == "diff")
{
omit(card, "diff_lines", "unsupported_source", None);
}
bound_card(card);
}
pub(super) fn project_task_telemetry(
card: &mut Value,
task: &tool_card::TaskTelemetry,
persistence: &str,
) {
if let Some(intent) = &task.intent {
card["title"] = json!(intent);
}
card["blocks"] = json!([block(&task.block(persistence))]);
if !task.activity.is_empty() {
let rows = task
.activity
.iter()
.map(|text| json!({"label":"Task activity","value":text,"outcome":"unknown"}))
.collect::<Vec<_>>();
card["blocks"]
.as_array_mut()
.unwrap()
.push(json!({"type":"results","rows":rows}));
}
card["omissions"]
.as_array_mut()
.unwrap()
.retain(|omission| omission["subject"] != "blocks");
if task.text_limited || task.activity_omitted.is_some_and(|count| count > 0) {
omit(card, "text", "size_limit", None);
}
if task.changed_paths.is_some_and(|count| count > 0)
|| task.calls_changed.is_some_and(|count| count > 0)
{
card["changes"] = json!(if task.outcome.as_deref() == Some("success") {
"applied"
} else {
"partial"
});
}
bound_card(card);
}
pub(super) fn block(block: &ToolBlock) -> Value {
match block {
ToolBlock::Text { lines } => {
json!({"type":"text","text":lines.iter().map(|line| line.plain_text()).collect::<Vec<_>>().join("\n"),"format":"plain","language":null})
}
ToolBlock::Command {
command,
working_directory,
exit_code,
} => {
json!({"type":"command","command":command,"working_directory":working_directory,"exit_code":exit_code.and_then(|code| i32::try_from(code).ok()),"signal":null})
}
ToolBlock::Results { rows } => {
json!({"type":"results","rows":rows.iter().map(|row| json!({"label":row.label,"value":row.value,"outcome":match row.outcome { tool_card::ResultOutcome::Success => "success", tool_card::ResultOutcome::Failure => "failure", tool_card::ResultOutcome::Partial => "partial", tool_card::ResultOutcome::Unknown => "unknown" }})).collect::<Vec<_>>()})
}
ToolBlock::Resources { rows } => {
json!({"type":"resources","rows":rows.iter().map(|row| json!({"kind":"resource","label":row.line.plain_text(),"location":row.location,"availability":match row.availability { tool_card::ResourceAvailability::Available => "available", tool_card::ResourceAvailability::Unavailable => "unavailable", tool_card::ResourceAvailability::Unknown => "unknown" }})).collect::<Vec<_>>()})
}
ToolBlock::Diff { application, lines } => {
json!({"type":"diff","old_path":null,"new_path":null,"application":match application { DiffApplication::Applied => "applied", DiffApplication::Partial => "partial", DiffApplication::Unknown => "unknown" },"lines":lines.iter().map(|line| {
let kind = if line.spans.iter().any(|span| span.meaning == TextMeaning::Added) { "added" } else { "removed" };
let text = line.plain_text();
json!({"kind":kind,"text":text.strip_prefix(if kind == "added" { '+' } else { '-' }).unwrap_or(&text),"old_line":null,"new_line":null})
}).collect::<Vec<_>>()})
}
ToolBlock::Progress {
label,
completed,
total,
} => {
json!({"type":"progress","label":label,"completed":completed,"total":total,"unit":"items"})
}
}
}
pub(super) fn merge_bash_notices(
card: &mut Value,
detail: &ToolActivityDetail,
notice_loss: &mut BashNoticeState,
) {
if crate::tools::ToolCapability::from_dispatch_name(&detail.tool_name)
!= Some(crate::tools::ToolCapability::Bash)
{
return;
}
let notices = tool_card::bash_notices(Some(detail));
let blocks = card["blocks"].as_array_mut().unwrap();
let position = blocks.iter().position(is_bash_notices);
let previous = position.map(|index| blocks.remove(index));
if previous.is_none() {
notice_loss.limited_entries = 0;
notice_loss.unsupported_entries = 0;
notice_loss.unsupported_notices = 0;
}
if notices.rows[3].value.is_some() {
notice_loss.limited_entries = notices.limited_entries;
notice_loss.unsupported_entries = notices.unsupported_entries;
}
if !detail.metadata["bash_file_tracking"].is_null() {
notice_loss.unsupported_notices = notices.unsupported_notices;
}
let rows = notices
.rows
.into_iter()
.enumerate()
.map(|(index, mut notice)| {
if index < 2 {
if let Some((_, label)) = notice
.value
.as_deref()
.and_then(tool_card::public_decision_label)
{
notice_loss.protection_labels[index] = Some(label);
}
notice.value = notice_loss.protection_labels[index].map(str::to_owned);
}
let value = notice
.value
.map(Value::String)
.or_else(|| {
previous
.as_ref()?
.get("rows")?
.as_array()?
.iter()
.find(|row| row["label"] == notice.label)
.map(|row| row["value"].clone())
})
.unwrap_or(Value::Null);
json!({"label":notice.label,"value":value,"outcome":"unknown"})
})
.collect::<Vec<_>>();
let missing = rows.iter().filter(|row| row["value"].is_null()).count();
let protection = rows
.iter()
.take(2)
.filter_map(|row| tool_card::public_decision_label(row["value"].as_str()?))
.map(|(decision, _)| decision)
.max();
let summary = rows
.iter()
.take(2)
.filter_map(|row| {
Some(format!(
"{} • {}",
row["label"].as_str()?,
row["value"].as_str()?
))
})
.collect::<Vec<_>>()
.join(" • ");
let omitted_blocks = blocks.len().saturating_sub(15);
blocks.truncate(15);
blocks.insert(0, json!({"type":"results","rows":rows}));
card["omissions"]
.as_array_mut()
.unwrap()
.retain(|omission| omission["subject"] != "rows");
if let Some(omission) = ¬ice_loss.source_rows {
card["omissions"]
.as_array_mut()
.unwrap()
.push(omission.clone());
}
if let Some(decision) = protection {
if card["metadata"]["public_protection"]["decision"] != "blocked"
|| decision == PublicProtectionDecision::Denied
{
card["metadata"]["public_protection"] = json!({"decision":match decision {
PublicProtectionDecision::NotReported => "not_reported",
PublicProtectionDecision::Allowed => "allowed",
PublicProtectionDecision::ApprovalRequired => "approval_required",
PublicProtectionDecision::Blocked => "blocked",
PublicProtectionDecision::Denied => "denied",
}, "summary":summary});
}
}
if omitted_blocks > 0 {
omit(card, "blocks", "size_limit", Some(omitted_blocks));
}
if missing > 0 {
omit(
card,
"rows",
if card["origin"] == "saved" {
"not_recorded"
} else {
"missing_source"
},
Some(missing),
);
}
if notice_loss.limited_entries > 0 {
omit(
card,
"rows",
"size_limit",
Some(notice_loss.limited_entries),
);
}
if notice_loss.unsupported_entries > 0 {
omit(
card,
"rows",
"unsupported_source",
Some(notice_loss.unsupported_entries),
);
}
if notice_loss.unsupported_notices > 0 {
omit(
card,
"rows",
"unsupported_source",
Some(notice_loss.unsupported_notices),
);
}
update_availability(card);
}
fn is_bash_notices(block: &Value) -> bool {
block["type"] == "results" && block["rows"][0]["label"] == "Humanize protection"
}
fn reason_priority(reason: &str) -> usize {
[
"redacted",
"missing_source",
"not_recorded",
"unsupported_source",
"budget_limit",
"size_limit",
]
.iter()
.position(|value| *value == reason)
.unwrap_or(6)
}
pub(super) fn omit(card: &mut Value, subject: &str, reason: &str, count: Option<usize>) {
let omissions = card["omissions"].as_array_mut().expect("card omissions");
if let Some(existing) = omissions
.iter_mut()
.find(|value| value["subject"] == subject)
{
if reason_priority(reason) < reason_priority(existing["reason"].as_str().unwrap_or("")) {
existing["reason"] = json!(reason);
}
existing["omitted_count"] = Value::Null; } else {
omissions.push(json!({"subject":subject,"reason":reason,"omitted_count":count}));
}
update_availability(card);
}
fn update_availability(card: &mut Value) {
let reason = card["omissions"]
.as_array()
.unwrap()
.iter()
.filter_map(|value| value["reason"].as_str())
.min_by_key(|reason| reason_priority(reason))
.map(str::to_owned);
card["detail"] = match reason {
Some(reason) => {
json!({"availability":if card["blocks"].as_array().unwrap().is_empty() { "unavailable" } else { "truncated" },"reason":reason})
}
None => json!({"availability":"complete","reason":null}),
};
}
fn bound_strings(
value: &mut Value,
key: &str,
losses: &mut Vec<(&'static str, &'static str, Option<usize>)>,
) {
match value {
Value::String(text) => {
if matches!(
key,
"activity_id" | "card_id" | "parent_activity_id" | "task_id"
) {
return;
}
let limit = match key {
"title" => 256,
"tool_name" | "resource_path" | "working_directory" | "old_path" | "new_path" => {
512
}
"summary" => 256,
"language" | "signal" => 64,
"command" => 4096,
"text" => 1024,
_ => 1024,
};
let subject = match key {
"title" => "title",
"tool_name" | "resource_path" | "working_directory" | "summary" => "metadata",
_ => "text",
};
let safe = crate::output::sanitize_display_text(text);
if safe != *text {
*text = safe;
losses.push((subject, "redacted", None));
}
if text.contains("<redacted>") {
losses.push((subject, "redacted", None));
}
if text.len() > limit {
let mut end = limit;
while !text.is_char_boundary(end) {
end -= 1;
}
losses.push((subject, "size_limit", Some(text[end..].chars().count())));
text.truncate(end);
}
}
Value::Array(values) => {
for value in values {
bound_strings(value, key, losses);
}
}
Value::Object(fields) => {
for (key, value) in fields {
bound_strings(value, key, losses);
}
}
Value::Number(number)
if number
.as_u64()
.is_some_and(|number| number > 9_007_199_254_740_991) =>
{
*value = Value::Null;
losses.push(("progress", "unsupported_source", None));
}
_ => {}
}
}
pub(super) fn bytes(card: &Value) -> usize {
serde_json::to_vec(card).expect("wire card").len()
}
fn refresh_collection_facts(card: &mut Value) {
let blocks = card["blocks"].as_array_mut().unwrap();
let shown_children = blocks
.iter()
.filter_map(|block| block["children"].as_array())
.map(Vec::len)
.sum::<usize>();
let shown_warnings = blocks
.iter()
.filter_map(|block| block["rows"].as_array())
.flatten()
.filter(|row| row["label"] == "Warning")
.count();
let shown_paths = blocks
.iter()
.filter_map(|block| block["rows"].as_array())
.flatten()
.filter(|row| row["label"] == "Changed path")
.count();
let shown_activity = blocks
.iter()
.filter_map(|block| block["rows"].as_array())
.flatten()
.filter(|row| row["label"] == "Task activity")
.count();
for rows in blocks
.iter_mut()
.filter_map(|block| block["rows"].as_array_mut())
{
let count = |label: &str| {
rows.iter()
.find(|row| row["label"] == label)
.and_then(|row| row["value"].as_str())
.and_then(|value| value.parse::<u64>().ok())
};
let total = count("Total calls").or_else(|| count("Tasks total"));
let warnings = count("Warnings");
let paths = count("Changed paths");
let activity = count("Task activity count");
let aggregate = rows
.iter()
.any(|row| row["label"] == "Total calls" || row["label"] == "Tasks total");
if aggregate {
for (label, value) in [
(
"Child references complete",
total
.filter(|total| *total >= shown_children as u64)
.map(|total| (total == shown_children as u64).to_string()),
),
(
"Child references omitted",
total
.and_then(|total| total.checked_sub(shown_children as u64))
.map(|value| value.to_string()),
),
] {
if let Some(row) = rows.iter_mut().find(|row| row["label"] == label) {
row["value"] = json!(value);
} else {
rows.push(json!({"label":label,"value":value,"outcome":"unknown"}));
}
}
}
for row in rows {
let value = match row["label"].as_str() {
Some("Task activity omitted") => {
activity.and_then(|total| total.checked_sub(shown_activity as u64))
}
Some("Warnings omitted") => {
warnings.and_then(|total| total.checked_sub(shown_warnings as u64))
}
Some("Changed paths omitted") => {
paths.and_then(|total| total.checked_sub(shown_paths as u64))
}
_ => continue,
};
row["value"] = json!(value.map(|value| value.to_string()));
}
}
}
pub(super) fn bound_card(card: &mut Value) {
refresh_collection_facts(card);
if card["tool_kind"] == "code_mode" && card["state"] == "finished" {
let outcome = card["outcome"].clone();
for row in card["blocks"]
.as_array_mut()
.unwrap()
.iter_mut()
.filter_map(|block| block["rows"].as_array_mut())
.flatten()
{
if row["label"] == "Script outcome" {
row["value"] = outcome.clone();
} else if row["label"] == "Completed with warnings" && outcome != "success" {
row["value"] = Value::Null;
}
}
}
let mut losses = Vec::new();
bound_strings(card, "", &mut losses);
for (subject, reason, count) in losses {
omit(card, subject, reason, count);
}
let mut preview_bytes = 0;
let mut preview_omitted = false;
card["blocks"].as_array_mut().unwrap().retain_mut(|block| {
let size = block["text"].as_str().map_or(0, str::len)
+ block["command"].as_str().map_or(0, str::len);
if preview_bytes + size > 4096 {
preview_omitted = true;
if block["type"] == "command" {
block["command"] = Value::Null;
true } else {
false
}
} else {
preview_bytes += size;
true
}
});
if preview_omitted {
omit(card, "text", "size_limit", None);
}
while bytes(card) > CARD_BYTES {
let blocks = card["blocks"].as_array_mut().unwrap();
let Some(index) = blocks
.iter()
.rposition(|block| !is_execution_core_block(block))
else {
break;
};
let removed = blocks.remove(index);
if removed["type"] == "children" {
omit(card, "children", "size_limit", None);
}
omit(card, "blocks", "size_limit", None);
}
refresh_collection_facts(card);
if bytes(card) > CARD_BYTES {
make_core(card, "size_limit");
}
update_availability(card);
}
fn is_execution_core_block(block: &Value) -> bool {
block["type"] == "command"
|| (block["type"] == "results"
&& block["rows"].as_array().is_some_and(|rows| {
rows.iter().any(|row| {
matches!(
row["label"].as_str(),
Some("Command timeout" | "Output truncation" | "Cleanup warning")
) || row["label"]
.as_str()
.is_some_and(tool_card::execution_core_label)
})
}))
}
pub(super) fn execution_core_facts(card: &Value) -> Vec<Value> {
card["blocks"]
.as_array()
.into_iter()
.flatten()
.filter(|block| is_execution_core_block(block))
.cloned()
.collect()
}
pub(super) fn make_core(card: &mut Value, reason: &str) {
if !card["blocks"].as_array().unwrap().is_empty() {
if card["blocks"]
.as_array()
.unwrap()
.iter()
.any(|block| block["type"] == "children")
{
omit(card, "children", reason, None);
}
let mut retained = Vec::new();
let mut shortened = false;
for block in card["blocks"].as_array().unwrap() {
if !is_execution_core_block(block) {
continue;
}
let mut block = block.clone();
if block["type"] == "command" {
shortened |= !block["command"].is_null();
block["command"] = Value::Null;
block["working_directory"] = Value::Null;
} else {
block["rows"].as_array_mut().unwrap().retain(|row| {
(matches!(
row["label"].as_str(),
Some("Command timeout" | "Output truncation" | "Cleanup warning")
) && !row["value"].is_null())
|| row["label"]
.as_str()
.is_some_and(tool_card::execution_core_label)
});
for row in block["rows"].as_array_mut().unwrap() {
if let Some(value) = row["value"].as_str().filter(|value| value.len() > 128) {
let mut end = 128 - "… [truncated]".len();
while !value.is_char_boundary(end) {
end -= 1;
}
row["value"] = json!(format!("{}… [truncated]", &value[..end]));
shortened = true;
}
}
}
retained.push(block);
}
card["blocks"] = json!(retained);
omit(card, "blocks", reason, None);
if shortened {
omit(card, "text", reason, None);
}
}
if card["title"] != "Tool" {
card["title"] = json!("Tool");
omit(card, "title", reason, None);
}
for key in ["tool_name", "resource_path", "working_directory"] {
if !card["metadata"][key].is_null() {
card["metadata"][key] = Value::Null;
omit(card, "metadata", reason, None);
}
}
if !card["metadata"]["public_protection"]["summary"].is_null() {
card["metadata"]["public_protection"]["summary"] = Value::Null;
omit(card, "metadata", reason, None);
}
refresh_collection_facts(card);
update_availability(card);
}