use super::*;
use serde::{Deserialize, Serialize};
use serde_json::Value;
const REPORT_BYTES: usize = 12 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ResourceReport {
schema_version: u8,
tool_name: String,
facts: Vec<Fact>,
resources: Vec<ReadResource>,
limited: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Fact {
label: String,
value: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReadResource {
location: String,
outcome: Option<String>,
supplied_lines: Option<u64>,
retained_output_lines: Option<u64>,
completeness: Option<String>,
reason: Option<String>,
}
const READ: &[(&str, &str)] = &[
("Requested resources", "files"),
("Result state", "result_state"),
("Succeeded resources", "succeeded"),
("Failed resources", "failed"),
("Omitted resources", "omitted"),
("Read complete", "read_complete"),
("Results limited", "truncated"),
("Aggregate read limited", "aggregate_truncated"),
];
const GREP: &[(&str, &str)] = &[
("Matching lines returned", "matches_returned"),
("Result state", "result_state"),
("Total matching lines", "total_matches"),
("Context lines returned", "context_lines_returned"),
("Output lines returned", "output_lines_returned"),
("Matching lines seen", "matches_seen"),
("Seen count exact", "matches_seen_exact"),
("Files returned", "files_returned"),
("Scan complete", "scan_complete"),
("Timed out", "timed_out"),
("Results limited", "truncated"),
("Next offset", "next_offset"),
("Has more", "has_more"),
("Context lines omitted", "context_lines_omitted"),
("Read errors skipped", "read_errors_skipped"),
("Walk errors", "walk_errors"),
("Walk entries omitted", "walk_entries_omitted"),
("File byte limit reached", "byte_limit_reached"),
("Scan byte limit reached", "scan_byte_limit_reached"),
("File limit reached", "file_limit_reached"),
("Match limit reached", "match_limit_reached"),
("Per-file limit reached", "per_file_limit_reached"),
("Output byte limit reached", "output_byte_limit_reached"),
("Raw lookahead reached", "raw_lookahead_reached"),
];
const AST: &[(&str, &str)] = &[
("Search output lines returned", "matches_returned"),
("Result state", "result_state"),
("Scan complete", "ast_scan_complete"),
("Outline entries returned", "symbols_returned"),
("Timed out", "timed_out"),
("Results limited", "truncated"),
("Partial scan", "partial"),
("Output truncated", "stdout_truncated"),
("Files skipped", "files_skipped"),
("Workset limited", "workset_truncated"),
];
const FIND: &[(&str, &str)] = &[
("Entries returned", "matches_returned"),
("Result state", "result_state"),
("Total matching entries", "total_matches"),
("Matching entries seen", "total_matched"),
("Seen count exact", "total_matched_exact"),
("Scan complete", "scan_complete"),
("Timed out", "timed_out"),
("Results limited", "truncated"),
("Has more", "has_more"),
("Next offset", "next_offset"),
("Entries scanned", "entries_scanned"),
("Entry limit reached", "entry_limit_reached"),
("Walk errors", "walk_errors"),
("Walk entries omitted", "walk_entries_omitted"),
];
const LIST: &[(&str, &str)] = &[
("Files returned", "files"),
("Directories returned", "directories"),
("Result state", "result_state"),
("Entries returned", "entries_returned"),
("Listing complete", "listing_complete"),
("Results limited", "truncated"),
];
fn fields(tool: &str) -> Option<&'static [(&'static str, &'static str)]> {
Some(match ToolCapability::from_dispatch_name(tool)? {
ToolCapability::Read => READ,
ToolCapability::Grep => GREP,
ToolCapability::AstGrep => AST,
ToolCapability::Find => FIND,
ToolCapability::ListFiles => LIST,
_ => return None,
})
}
fn scalar(value: &Value) -> Option<String> {
value
.as_u64()
.map(|value| value.to_string())
.or_else(|| value.as_bool().map(|value| value.to_string()))
}
fn boolean_field(key: &str) -> bool {
matches!(
key,
"read_complete"
| "truncated"
| "aggregate_truncated"
| "matches_seen_exact"
| "total_matched_exact"
| "scan_complete"
| "ast_scan_complete"
| "listing_complete"
| "timed_out"
| "has_more"
| "byte_limit_reached"
| "scan_byte_limit_reached"
| "file_limit_reached"
| "match_limit_reached"
| "per_file_limit_reached"
| "output_byte_limit_reached"
| "raw_lookahead_reached"
| "entry_limit_reached"
| "partial"
| "stdout_truncated"
| "workset_truncated"
)
}
fn fact_scalar(key: &str, value: &Value) -> Option<String> {
if boolean_field(key) {
value.as_bool().map(|value| value.to_string())
} else {
value.as_u64().map(|value| value.to_string())
}
}
fn ast_scan_complete(metadata: &Value) -> Option<bool> {
match (
metadata["partial"].as_bool(),
metadata["truncated"].as_bool(),
metadata["timed_out"].as_bool(),
) {
(Some(false), Some(false), Some(false)) => Some(true),
(Some(true), _, _) | (_, Some(true), _) | (_, _, Some(true)) => Some(false),
_ => None,
}
}
fn result_state(detail: &ToolActivityDetail) -> Option<&'static str> {
if detail.status == ActivityStatus::Failed {
return Some("failed");
}
if detail.status != ActivityStatus::Success {
return None;
}
let metadata = &detail.metadata;
let (complete, returned, total) = match ToolCapability::from_dispatch_name(&detail.tool_name)? {
ToolCapability::Read => (
metadata["structured_result"]["complete"].as_bool(),
metadata["succeeded"].as_u64(),
None,
),
ToolCapability::Grep => (
metadata["scan_complete"].as_bool(),
metadata["matches_returned"].as_u64(),
(metadata["matches_seen_exact"] == true)
.then(|| metadata["matches_seen"].as_u64())
.flatten(),
),
ToolCapability::Find => (
metadata["scan_complete"].as_bool(),
metadata["matches_returned"].as_u64(),
(metadata["total_matched_exact"] == true)
.then(|| metadata["total_matched"].as_u64())
.flatten(),
),
ToolCapability::AstGrep => {
let count = metadata["symbols_returned"]
.as_u64()
.or_else(|| metadata["matches_returned"].as_u64());
(ast_scan_complete(metadata), count, count)
}
ToolCapability::ListFiles => {
let count = metadata["files"]
.as_u64()
.zip(metadata["directories"].as_u64())
.and_then(|(files, directories)| files.checked_add(directories));
(
metadata["truncated"].as_bool().map(|limited| !limited),
count,
count,
)
}
_ => return None,
};
if complete == Some(false) || metadata["truncated"] == true {
return Some("incomplete");
}
if complete == Some(true) && total == Some(0) {
return Some(if detail.tool_name.as_ref() == "list_files" {
"empty listing"
} else {
"no matches"
});
}
if returned == Some(0) {
return Some("no entries returned");
}
if returned.is_some_and(|count| count > 0) {
return Some("results returned");
}
complete.filter(|complete| *complete).map(|_| "complete")
}
fn result_outcome(value: Option<&str>) -> ResultOutcome {
match value {
Some("success" | "complete" | "no matches" | "empty listing") => ResultOutcome::Success,
Some("failure" | "failed") => ResultOutcome::Failure,
Some("omitted" | "incomplete" | "partial") => ResultOutcome::Partial,
_ => ResultOutcome::Unknown,
}
}
impl ResourceReport {
pub(crate) fn select(detail: &ToolActivityDetail) -> Option<Self> {
let fields = fields(&detail.tool_name)?;
let metadata = &detail.metadata;
let mut report = Self {
schema_version: 1,
tool_name: detail.tool_name.to_string(),
facts: fields
.iter()
.map(|(label, key)| Fact {
label: (*label).into(),
value: match *key {
"result_state" => result_state(detail).map(str::to_owned),
"ast_scan_complete" => {
ast_scan_complete(metadata).map(|value| value.to_string())
}
"listing_complete" => metadata["truncated"]
.as_bool()
.map(|limited| (!limited).to_string()),
"entries_returned" => metadata["files"]
.as_u64()
.zip(metadata["directories"].as_u64())
.and_then(|(files, directories)| files.checked_add(directories))
.map(|count| count.to_string()),
"context_lines_returned" => metadata["output_lines_returned"]
.as_u64()
.zip(metadata["matches_returned"].as_u64())
.and_then(|(output, primary)| output.checked_sub(primary))
.and_then(|auxiliary| match metadata["mode"].as_str() {
Some("raw") => Some(auxiliary),
Some("ranked") => metadata["files_returned"]
.as_u64()
.and_then(|headers| auxiliary.checked_sub(headers)),
_ => None,
})
.map(|count| count.to_string()),
"total_matches" => {
let grep = detail.tool_name.as_ref() == "grep";
(metadata[if grep {
"matches_seen_exact"
} else {
"total_matched_exact"
}] == true)
.then(|| {
fact_scalar(
"total_matches",
&metadata[if grep {
"matches_seen"
} else {
"total_matched"
}],
)
})
.flatten()
}
"read_complete" => {
fact_scalar("read_complete", &metadata["structured_result"]["complete"])
}
"next_offset" if detail.tool_name.as_ref() == "find" => fact_scalar(
"next_offset",
&metadata["structured_result"]["next_offset"],
),
_ => fact_scalar(key, &metadata[*key]),
},
})
.collect(),
resources: Vec::new(),
limited: false,
};
if ToolCapability::from_dispatch_name(&detail.tool_name) == Some(ToolCapability::Read) {
let targets = detail.params["paths"]
.as_array()
.cloned()
.or_else(|| {
detail.params["path"]
.as_str()
.map(|path| vec![Value::String(path.into())])
})
.unwrap_or_default();
report.limited |= targets.len() > 8;
let results = metadata["results"].as_array();
for (index, target) in targets.iter().take(8).enumerate() {
let Some(target) = target.as_str() else {
continue;
};
let resource = if let Some(results) = results {
results.get(index).unwrap_or(&Value::Null)
} else if targets.len() == 1 {
metadata
} else {
&Value::Null
};
let success = resource["success"].as_bool().or_else(|| {
(results.is_none() && targets.len() == 1)
.then_some(match detail.status {
ActivityStatus::Success => Some(true),
ActivityStatus::Failed => Some(false),
_ => None,
})
.flatten()
});
let omitted = resource["omitted"].as_bool() == Some(true);
let outcome = if omitted {
Some("omitted")
} else {
success.map(|success| if success { "success" } else { "failure" })
};
let truncated = resource["truncated"].as_bool();
let completeness = if omitted {
Some("omitted")
} else if success == Some(false) {
Some("unavailable")
} else if success == Some(true) {
truncated.map(|limited| if limited { "partial" } else { "complete" })
} else {
None
};
let retained_text = metadata["structured_result"]["files"]
.as_array()
.and_then(|files| files.get(index))
.and_then(|file| file["text"].as_str());
let retained_output_lines = retained_text.map(|text| text.lines().count() as u64);
let supplied_lines = (success == Some(true)
&& (metadata["aggregate_truncated"] == false
|| (results.is_none() && targets.len() == 1)))
.then(|| {
resource["hashline_seen_lines"]
.as_array()
.map(|lines| lines.len() as u64)
.or_else(|| {
(truncated == Some(false))
.then_some(retained_output_lines)
.flatten()
})
})
.flatten();
let reason = resource["error"]
.as_str()
.or_else(|| {
metadata["structured_result"]["files"]
.as_array()
.and_then(|files| files.get(index))
.and_then(|file| file["error"].as_str())
})
.or_else(|| {
(success == Some(false) && targets.len() == 1)
.then_some(detail.output.as_ref())
})
.and_then(|error| {
error
.lines()
.find(|line| !line.trim().is_empty())
.map(str::to_owned)
})
.or_else(|| (truncated == Some(true)).then(|| "selected read limited".into()));
report.resources.push(ReadResource {
location: target.into(),
outcome: outcome.map(str::to_owned),
supplied_lines,
retained_output_lines,
completeness: completeness.map(str::to_owned),
reason,
});
}
}
for text in report.resources.iter_mut().flat_map(|resource| {
std::iter::once(&mut resource.location).chain(resource.reason.iter_mut())
}) {
*text = sanitize_display_text(text);
if text.len() > 512 {
let mut end = 512 - "…".len();
while !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
text.push('…');
report.limited = true;
}
}
if serde_json::to_vec(&report).expect("resource report").len() > REPORT_BYTES {
report.limited = true;
for resource in &mut report.resources {
resource.reason = None;
}
}
if serde_json::to_vec(&report).expect("resource report").len() > REPORT_BYTES {
report.resources.clear();
report.limited = true;
}
Some(report)
}
pub(crate) fn into_value(self) -> Value {
serde_json::to_value(self).expect("resource report")
}
pub(crate) fn restore(detail: &mut ToolActivityDetail, payload: &Value) {
let Some(value) = payload
.get("resource_display")
.filter(|value| !value.is_null())
else {
return;
};
let parsed = (serde_json::to_vec(value).is_ok_and(|bytes| bytes.len() <= REPORT_BYTES))
.then(|| serde_json::from_value::<Self>(value.clone()).ok())
.flatten();
let valid = parsed.filter(|report| {
report.schema_version == 1
&& report.tool_name == detail.tool_name.as_ref()
&& fields(&report.tool_name).is_some_and(|fields| {
report.facts.len() == fields.len()
&& report.facts.iter().zip(fields).all(|(fact, (label, key))| {
fact.label == *label
&& fact.value.as_deref().is_none_or(|value| {
if fact.label == "Result state" {
matches!(
value,
"failed"
| "incomplete"
| "no matches"
| "empty listing"
| "no entries returned"
| "results returned"
| "complete"
)
} else if boolean_field(key) {
value == "true" || value == "false"
} else {
value.parse::<u64>().is_ok()
}
})
})
})
&& report.resources.len() <= 8
&& (report.resources.is_empty()
|| ToolCapability::from_dispatch_name(&report.tool_name)
== Some(ToolCapability::Read))
&& report.resources.iter().all(|resource| {
resource.location.len() <= 512
&& !resource.location.chars().any(char::is_control)
&& sanitize_display_text(&resource.location) == resource.location
&& resource.reason.as_deref().is_none_or(|reason| {
reason.len() <= 512
&& reason.lines().count() <= 1
&& sanitize_display_text(reason) == reason
})
&& resource
.outcome
.as_deref()
.is_none_or(|value| matches!(value, "success" | "failure" | "omitted"))
&& resource.completeness.as_deref().is_none_or(|value| {
matches!(value, "complete" | "partial" | "omitted" | "unavailable")
})
})
});
let malformed = valid.is_none();
detail.resource_report = valid.or_else(|| {
Self::select(detail).map(|mut report| {
for fact in &mut report.facts {
fact.value = None;
}
for resource in &mut report.resources {
resource.outcome = None;
resource.supplied_lines = None;
resource.retained_output_lines = None;
resource.completeness = None;
resource.reason = None;
}
report
})
});
if malformed && fields(&detail.tool_name).is_some() {
detail.metadata = Value::Null;
}
}
pub(super) fn blocks(&self) -> Vec<ToolBlock> {
let mut blocks = vec![ToolBlock::Results {
rows: self
.facts
.iter()
.map(|fact| ResultRow {
label: fact.label.clone(),
value: fact.value.clone(),
outcome: if fact.label == "Result state" {
result_outcome(fact.value.as_deref())
} else {
ResultOutcome::Unknown
},
})
.collect(),
}];
if !self.resources.is_empty() {
blocks.push(ToolBlock::Resources {
rows: self
.resources
.iter()
.map(|resource| ResourceRow {
line: TextLine::from_span(&resource.location, TextMeaning::Resource),
location: resource.location.clone(),
availability: match resource.outcome.as_deref() {
Some("success") => ResourceAvailability::Available,
Some("failure" | "omitted") => ResourceAvailability::Unavailable,
_ => ResourceAvailability::Unknown,
},
})
.collect(),
});
let rows: Vec<_> = self
.resources
.iter()
.enumerate()
.flat_map(|(index, resource)| {
[
("outcome", resource.outcome.clone()),
(
"supplied source lines",
resource.supplied_lines.map(|count| count.to_string()),
),
(
"retained output lines",
resource
.retained_output_lines
.map(|count| count.to_string()),
),
("completeness", resource.completeness.clone()),
("reason", resource.reason.clone()),
]
.map(|(label, value)| ResultRow {
label: format!("Resource {} {label}", index + 1),
outcome: if label == "outcome" {
result_outcome(value.as_deref())
} else {
ResultOutcome::Unknown
},
value,
})
})
.collect();
blocks.extend(rows.chunks(32).map(|rows| ToolBlock::Results {
rows: rows.to_vec(),
}));
}
blocks
}
}
pub(super) fn argument_blocks(detail: &ToolActivityDetail) -> Vec<ToolBlock> {
let params = &detail.params;
let capability = ToolCapability::from_dispatch_name(&detail.tool_name);
let operation = match capability {
Some(ToolCapability::AstGrep) => match params["operation"].as_str() {
Some("outline") => Some("outline"),
Some("search") | None if params["pattern"].as_str().is_some() => {
Some(if params["rewrite"].as_str().is_some() {
"rewrite preview"
} else {
"search"
})
}
_ => None,
},
Some(ToolCapability::Read) => Some("read"),
Some(ToolCapability::Grep) => Some("grep"),
Some(ToolCapability::Find) => Some("find"),
Some(ToolCapability::ListFiles) => Some("list files"),
_ => return Vec::new(),
};
let mut rows = vec![ResultRow {
label: "Resource operation".into(),
value: operation.map(str::to_owned),
outcome: ResultOutcome::Unknown,
}];
let keys: &[(&str, &str)] = match capability {
Some(ToolCapability::Grep) => &[
("Patterns", "patterns"),
("Scope", "path"),
("Mode", "mode"),
("Limit", "limit"),
("Offset", "offset"),
("Context requested", "context"),
],
Some(ToolCapability::AstGrep) => &[
("Scope", "path"),
("Language", "language"),
("Pattern", "pattern"),
("Name filter", "name"),
("Symbol type filter", "symbol_type"),
("Items", "items"),
("View", "view"),
("Public members only", "pub_members"),
("Limit", "limit"),
],
Some(ToolCapability::Find) => &[
("Query", "query"),
("Scope", "path"),
("Kind", "kind"),
("Limit", "limit"),
("Offset", "offset"),
],
Some(ToolCapability::ListFiles) => &[
("Directory", "path"),
("Include directories", "include_directories"),
],
_ => &[],
};
rows.extend(keys.iter().map(|(label, key)| {
ResultRow {
label: (*label).into(),
outcome: ResultOutcome::Unknown,
value: params
.get(*key)
.filter(|value| !value.is_null())
.or_else(|| detail.metadata.get(*key))
.and_then(|value| value.as_str().map(str::to_owned).or_else(|| scalar(value)))
.or_else(|| {
(*key == "patterns")
.then(|| {
params[*key]
.as_array()
.filter(|patterns| patterns.iter().all(Value::is_string))
.map(|patterns| serde_json::to_string(patterns).expect("patterns"))
})
.flatten()
}),
}
}));
vec![ToolBlock::Results { rows }]
}
pub(super) fn append_blocks(blocks: &mut Vec<ToolBlock>, detail: Option<&ToolActivityDetail>) {
let Some(detail) = detail else { return };
let Some(report) = detail
.resource_report
.clone()
.or_else(|| ResourceReport::select(detail))
else {
return;
};
let mut selected = argument_blocks(detail);
selected.extend(report.blocks());
if detail.tool_name.as_ref() == "read" {
blocks.clear();
}
selected.append(blocks);
*blocks = selected;
}
pub(super) fn limited(detail: Option<&ToolActivityDetail>) -> bool {
detail
.and_then(|detail| detail.resource_report.as_ref())
.is_some_and(|report| report.limited)
}