mod adapters;
mod bash_notices;
mod bounds;
mod code_mode;
mod execution_facts;
mod model;
mod protection;
mod public_facts;
mod resource_facts;
mod subagents;
mod task_telemetry;
use crate::output::{
ActivityStatus, ToolActivityDetail, redact_sensitive_text, sanitize_display_controls,
sanitize_display_text,
};
use crate::tools::ToolCapability;
pub(crate) use bash_notices::bash_notices;
pub(crate) use execution_facts::core_label as execution_core_label;
pub(crate) use model::*;
pub(crate) use protection::{PublicProtection, PublicProtectionDecision, public_decision_label};
pub(crate) use resource_facts::ResourceReport;
pub(crate) use subagents::{
TaskActivityFacts, TaskFacts, project_task, project_task_activity, task_counts,
};
pub(crate) use task_telemetry::{TaskBatchTelemetry, TaskTelemetry};
const TOOL_PREVIEW_LINES: usize = 4;
const PREVIEW_LINE_MAX_CHARS: usize = 240;
pub(crate) struct ToolCardInput<'a> {
pub(crate) tool_name: &'a str,
pub(crate) detail: Option<&'a ToolActivityDetail>,
pub(crate) status: Option<ActivityStatus>,
pub(crate) interrupted: bool,
pub(crate) bash_command: Option<&'a str>,
pub(crate) bash_mode_response: bool,
pub(crate) preview: &'a str,
pub(crate) body: &'a str,
pub(crate) children: &'a [ChildToolFacts<'a>],
pub(crate) children_complete: bool,
}
pub(crate) fn project_tool_card(input: &ToolCardInput<'_>) -> ToolCard {
let mut draft = adapters::project_tool_card(input);
draft.title = normalize_inline_separators(&sanitize_display_text(&draft.title));
draft.metadata = draft
.metadata
.into_iter()
.map(|text| sanitize_display_text(&text))
.collect();
draft.body = sanitize_display_text(&draft.body);
let observed_status = input
.status
.or_else(|| input.detail.map(|detail| detail.status));
let outcome = match observed_status {
Some(ActivityStatus::Success) => Some(ToolOutcome::Success),
Some(ActivityStatus::Canceled) => Some(ToolOutcome::Cancelled),
Some(ActivityStatus::Failed)
if draft.status == CardStatus::Canceled
|| ((ToolCapability::from_dispatch_name(input.tool_name).is_some()
|| input.tool_name == crate::code_mode::TOOL_NAME)
&& input.detail.is_some_and(|detail| {
detail.metadata["error_kind"].as_str() == Some("canceled")
})) =>
{
Some(ToolOutcome::Cancelled)
}
Some(ActivityStatus::Failed) => Some(ToolOutcome::Failure),
Some(ActivityStatus::Interrupted) => Some(ToolOutcome::Interrupted),
_ if input.interrupted => Some(ToolOutcome::Interrupted),
Some(ActivityStatus::Queued | ActivityStatus::Running | ActivityStatus::Writing) => None,
None => Some(ToolOutcome::Unknown),
};
if matches!(
outcome,
Some(ToolOutcome::Interrupted | ToolOutcome::Unknown)
) {
draft.status = CardStatus::None;
}
let changes = change_state(input);
let mut blocks = model::blocks_from_draft(&draft, input, changes);
public_facts::append_blocks(&mut blocks, input, draft.role);
execution_facts::append_blocks(&mut blocks, input, draft.role, outcome);
resource_facts::append_blocks(&mut blocks, input.detail);
let public_protection = protection::public_protection(input, &draft.metadata);
let mut card = ToolCard {
role: draft.role,
status: draft.status,
outcome,
changes,
title: draft.title,
metadata: draft.metadata,
body: draft.body,
blocks,
activity_rows: draft.activity_rows,
omissions: Vec::new(),
public_protection,
};
if input.detail.is_none() {
bounds::record_omission(
&mut card.omissions,
OmissionSubject::Text,
OmissionReason::MissingSource,
None,
);
} else if card.role == ToolCardRole::Tool && !input.bash_mode_response {
bounds::record_omission(
&mut card.omissions,
OmissionSubject::Text,
OmissionReason::UnsupportedSource,
None,
);
}
if card.role == ToolCardRole::Bash && input.bash_command.is_none() {
bounds::record_omission(
&mut card.omissions,
OmissionSubject::Text,
OmissionReason::MissingSource,
None,
);
}
if card.role == ToolCardRole::CodeMode {
let execution = input
.detail
.map(|detail| &detail.metadata["code_mode_execution"]);
let total = execution
.and_then(|value| value["inner_calls"].as_u64())
.or_else(|| {
input
.children_complete
.then_some(input.children.len() as u64)
});
let completed = execution
.and_then(|value| {
value["successful"]
.as_u64()
.zip(value["recoverable_failures"].as_u64())
})
.and_then(|(succeeded, failed)| succeeded.checked_add(failed))
.or_else(|| {
input.children_complete.then(|| {
input
.children
.iter()
.filter(|child| {
matches!(
child.status,
ActivityStatus::Success | ActivityStatus::Failed
)
})
.count() as u64
})
});
let completed = completed.filter(|completed| total.is_none_or(|total| *completed <= total));
card.blocks.push(ToolBlock::Progress {
label: "Calls completed".into(),
completed,
total,
});
if total.is_none() || completed.is_none() {
bounds::record_omission(
&mut card.omissions,
OmissionSubject::Progress,
OmissionReason::MissingSource,
None,
);
}
if input.children.len() > TOOL_PREVIEW_LINES {
bounds::record_omission(
&mut card.omissions,
OmissionSubject::Children,
OmissionReason::SizeLimit,
Some(input.children.len() - TOOL_PREVIEW_LINES),
);
}
}
if resource_facts::limited(input.detail) {
bounds::record_omission(
&mut card.omissions,
OmissionSubject::Text,
OmissionReason::SizeLimit,
None,
);
}
record_source_limits(input, &mut card);
bounds::bound_card(&mut card);
card
}
fn record_source_limits(input: &ToolCardInput<'_>, card: &mut ToolCard) {
if input.bash_mode_response && preview_is_limited(input.preview, TOOL_PREVIEW_LINES) {
bounds::record_omission(
&mut card.omissions,
OmissionSubject::Text,
OmissionReason::SizeLimit,
None,
);
}
let Some(detail) = input.detail else {
return;
};
if card.role == ToolCardRole::Bash {
for key in ["cleanup_warning", "stderr"] {
if (key == "cleanup_warning" || detail.status == ActivityStatus::Failed)
&& detail.metadata[key].as_str().is_some_and(|text| {
text.lines()
.filter(|line| !line.trim().is_empty())
.take(2)
.count()
> 1
})
{
bounds::record_omission(
&mut card.omissions,
OmissionSubject::Text,
OmissionReason::SizeLimit,
None,
);
}
}
}
if matches!(card.role, ToolCardRole::Write | ToolCardRole::HashEdit)
&& detail.metadata["humanize_protection_denied"].as_bool() == Some(true)
&& detail.params[if card.role == ToolCardRole::Write {
"content"
} else {
"input"
}]
.as_str()
.is_some_and(|text| preview_is_limited(text, TOOL_PREVIEW_LINES))
{
bounds::record_omission(
&mut card.omissions,
OmissionSubject::Text,
OmissionReason::SizeLimit,
None,
);
}
if card.role == ToolCardRole::CodeMode
&& card.outcome == Some(ToolOutcome::Failure)
&& !detail.output.is_empty()
{
bounds::record_omission(
&mut card.omissions,
OmissionSubject::Text,
OmissionReason::UnsupportedSource,
None,
);
}
let preview_lines = match card.role {
ToolCardRole::Grep => Some(10),
ToolCardRole::AstGrep | ToolCardRole::Find | ToolCardRole::ListFiles => {
Some(TOOL_PREVIEW_LINES)
}
_ => None,
};
let limited = if matches!(card.role, ToolCardRole::Web | ToolCardRole::ViewImage) {
public_facts::response_preview_limited(detail) == Some(true)
} else {
preview_lines.is_some_and(|limit| preview_is_limited(&detail.output, limit))
};
if limited
|| (preview_lines.is_some()
|| matches!(card.role, ToolCardRole::Web | ToolCardRole::ViewImage))
&& detail.metadata["truncated"].as_bool() == Some(true)
{
bounds::record_omission(
&mut card.omissions,
OmissionSubject::Text,
OmissionReason::SizeLimit,
None,
);
}
if detail.metadata["diff_truncated"].as_bool() == Some(true)
|| detail.applied_diff.as_deref().is_some_and(|diff| {
diff.lines()
.filter(|line| {
(line.starts_with('+') || line.starts_with('-'))
&& !line.starts_with("+++ ")
&& !line.starts_with("--- ")
})
.count()
> 10
})
{
bounds::record_omission(
&mut card.omissions,
OmissionSubject::DiffLines,
OmissionReason::SizeLimit,
None,
);
}
if card.role == ToolCardRole::HashEdit
&& detail.applied_diff.is_none()
&& detail.metadata["added"]
.as_u64()
.zip(detail.metadata["removed"].as_u64())
.is_some_and(|(added, removed)| added.saturating_add(removed) > 0)
{
bounds::record_omission(
&mut card.omissions,
OmissionSubject::DiffLines,
OmissionReason::NotRecorded,
None,
);
}
}
fn preview_is_limited(text: &str, line_limit: usize) -> bool {
text.lines().count() > line_limit
|| text
.lines()
.any(|line| line.chars().count() > PREVIEW_LINE_MAX_CHARS)
}
fn change_state(input: &ToolCardInput<'_>) -> ChangeState {
let capability = ToolCapability::from_dispatch_name(input.tool_name);
if matches!(
capability,
Some(
ToolCapability::Read
| ToolCapability::Grep
| ToolCapability::AstGrep
| ToolCapability::Find
| ToolCapability::ListFiles
| ToolCapability::Web
| ToolCapability::ViewImage
| ToolCapability::MagiControl
)
) {
return ChangeState::NotApplicable;
}
let Some(detail) = input.detail else {
return ChangeState::Unknown;
};
let metadata = &detail.metadata;
if capability == Some(ToolCapability::HashEdit) {
if metadata["changed"].as_u64() == Some(0) {
return ChangeState::None;
}
if metadata["outcome"].as_str() == Some("partial") {
return ChangeState::Partial;
}
if metadata["outcome"].as_str() == Some("stale_tag")
|| metadata["error_kind"].as_str() == Some("stale_tag")
|| metadata["humanize_protection_denied"].as_bool() == Some(true)
{
return ChangeState::None;
}
if let Some(files) = metadata["files"]
.as_array()
.filter(|files| !files.is_empty())
{
if files.iter().all(|file| {
file["operation"].as_str() == Some("noop")
&& file["status"].as_str() == Some("committed")
}) {
return ChangeState::None;
}
if files
.iter()
.all(|file| file["status"].as_str() == Some("committed"))
{
return ChangeState::Applied;
}
}
}
if capability == Some(ToolCapability::Write) {
if metadata["humanize_protection_denied"].as_bool() == Some(true) {
return ChangeState::None;
}
if metadata["bytes"].as_u64().is_some() {
return ChangeState::Applied;
}
}
if input.tool_name == crate::code_mode::TOOL_NAME {
let execution = &metadata["code_mode_execution"];
if execution["changed_path_count"]
.as_u64()
.is_some_and(|count| count > 0)
{
return if input_status(input) == CardStatus::Success
&& execution["recoverable_failures"].as_u64() == Some(0)
&& execution["unknown_outcomes"].as_u64() == Some(0)
&& execution["changed_paths_incomplete"].as_bool() == Some(false)
{
ChangeState::Applied
} else {
ChangeState::Partial
};
}
}
ChangeState::Unknown
}
fn input_status(input: &ToolCardInput<'_>) -> CardStatus {
input
.status
.or_else(|| input.detail.map(|detail| detail.status))
.map(card_status_from_activity)
.unwrap_or(CardStatus::None)
}
fn card_status_from_activity(status: ActivityStatus) -> CardStatus {
match status {
ActivityStatus::Queued | ActivityStatus::Running | ActivityStatus::Writing => {
CardStatus::Running
}
ActivityStatus::Success => CardStatus::Success,
ActivityStatus::Failed => CardStatus::Failed,
ActivityStatus::Canceled => CardStatus::Canceled,
ActivityStatus::Interrupted => CardStatus::None,
}
}
fn normalize_inline_separators(text: &str) -> String {
text.replace(" · ", " • ")
}
fn sanitized_single_line(text: &str) -> String {
sanitize_display_text(text)
.replace('\n', "\\n")
.replace('\t', "\\t")
}
fn first_non_empty_line(text: &str) -> Option<String> {
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|line| normalize_inline_separators(&sanitize_display_text(line)))
}
fn truncate_preview_line(text: &str, max_chars: usize) -> String {
const MARKER: &str = "… [truncated]";
if text.chars().count() <= max_chars {
return text.to_string();
}
use unicode_segmentation::UnicodeSegmentation;
let mut count = 0;
let mut text: String = text
.graphemes(true)
.take_while(|grapheme| {
count += grapheme.chars().count();
count <= max_chars.saturating_sub(MARKER.chars().count())
})
.collect();
text.push_str(MARKER);
text
}