use super::{ExistingPathPolicy, ReadContext};
use crate::{
cancellation::AgentCancellation,
persistence::{open_regular_file, read_regular_file_bounded},
tools::{
args::{FILE_READ_DEFAULT_LINES, FILE_READ_MAX_BYTES},
contract::metadata_key as meta,
hash_edit::{
format::{compute_file_hash, format_hashline_header},
normalize::normalize_for_snapshot_storage,
},
read_selector::{LineSelector, ReadSelector, split_selector_suffix},
skill_provenance::{SkillReadProvenance, finalize_skill_provenance},
},
};
use serde_json::{Map, Value, json};
use std::{
fmt::Write as _,
path::{Path, PathBuf},
};
impl ReadContext<'_> {
pub(super) fn resolve_existing_for_read(&self, path: &str) -> anyhow::Result<PathBuf> {
self.paths
.resolve_existing_path(path, ExistingPathPolicy::read(self.settings.absolute_paths))
}
pub(super) fn register_read_snapshot(
&self,
resource: &mut ReadResourceOutput,
delivered_bytes: usize,
) -> anyhow::Result<()> {
let Some((path, text, mut seen_lines)) = resource.snapshot.take() else {
return Ok(());
};
let mut end = resource.content.split('\n').next().map_or(0, str::len);
let delivered_lines = resource
.content
.split('\n')
.skip(1)
.take_while(|line| {
end += 1 + line.len();
end <= delivered_bytes
})
.count();
seen_lines.truncate(delivered_lines);
let tag = if let Some(pending) = self.pending_snapshots {
let tag = compute_file_hash(&text);
pending
.lock()
.map_err(|_| anyhow::anyhow!("pending read snapshot mutex poisoned"))?
.push(super::ReadSnapshot {
path,
text,
seen_lines: seen_lines.clone(),
});
tag
} else {
self.hashline_snapshots
.lock()
.map_err(|_| anyhow::anyhow!("hashline snapshot store mutex poisoned"))?
.record(path, text, seen_lines.clone())
};
resource
.metadata
.insert("hashline_tag".to_string(), json!(tag));
resource
.metadata
.insert("hashline_seen_lines".to_string(), json!(seen_lines));
Ok(())
}
pub(super) fn read_one_resource(
&self,
requested: &str,
cancellation: &AgentCancellation,
) -> anyhow::Result<ReadResourceOutput> {
cancellation.check()?;
let (target, selector) = self.split_read_target(requested)?;
if selector.raw
&& let Some(lines) = selector.lines
&& !target.contains("://")
{
let path = self.resolve_existing_for_read(target)?;
let file = open_regular_file(&path, false)?;
let bytes = file.metadata()?.len();
if bytes > FILE_READ_MAX_BYTES {
let excerpt =
crate::tools::read_stream::read_raw_excerpt(file, lines, cancellation)?;
let mut content = excerpt.text;
if content.ends_with('\n') {
content.pop();
}
if !excerpt.request_complete {
let reason = if excerpt.output_limited {
"output byte limit reached"
} else {
"scan byte limit reached"
};
write!(
content,
"\n[READ INCOMPLETE: {reason}; requested range not fully delivered]"
)?;
}
let metadata = json!({
"path": path, "bytes": bytes, "source": target, "kind": "file",
"raw": true, "selector": selector.metadata(), "total_lines": null,
"truncated": true, "request_complete": excerpt.request_complete,
"output_limited": excerpt.output_limited,
"bytes_scanned": excerpt.bytes_scanned,
"scan_limit_bytes": crate::tools::read_stream::SCAN_MAX_BYTES,
});
return Ok(ReadResourceOutput {
content,
metadata: metadata.as_object().expect("read metadata object").clone(),
truncated: true,
request_complete: excerpt.request_complete,
provenance: None,
snapshot: None,
});
}
}
let mut text = self.resolve_read_text(target)?;
let is_hashline_file = text.kind == "file" && !selector.raw;
if is_hashline_file {
let raw = std::mem::take(&mut text.content);
text.content = normalize_for_snapshot_storage(&raw);
}
let mut hashline_seen_lines = None;
let (content, total_lines, selector_truncated, output_limited) = if is_hashline_file {
let selection = select_file_lines(&text.content, selector);
let path = metadata_path(&text.metadata)?;
let formatted = format_hashline_read(&path, &text.content, &selection)?;
hashline_seen_lines = Some(formatted.seen_lines);
(
formatted.content,
selection.total_lines,
selection.truncated || formatted.truncated,
formatted.truncated,
)
} else {
let (content, total_lines, truncated) = selector.apply(&text.content);
(content, total_lines, truncated, false)
};
let snapshot = if is_hashline_file {
Some((
metadata_path(&text.metadata)?,
std::mem::take(&mut text.content),
hashline_seen_lines.unwrap_or_default(),
))
} else {
None
};
text.metadata
.insert(meta::TOTAL_LINES.to_string(), json!(total_lines));
let truncated = text.truncated || selector_truncated;
text.metadata
.insert(meta::TRUNCATED.to_string(), json!(truncated));
let request_complete = !text.truncated && !output_limited;
text.metadata
.insert("request_complete".to_string(), json!(request_complete));
text.metadata
.insert("output_limited".to_string(), json!(output_limited));
text.metadata
.insert("source".to_string(), json!(text.source.clone()));
text.metadata
.insert(meta::KIND.to_string(), json!(text.kind));
let selector_metadata = selector.metadata();
text.metadata
.insert("selector".to_string(), selector_metadata.clone());
text.metadata.insert("raw".to_string(), json!(selector.raw));
let provenance = if truncated || content.is_empty() {
text.metadata.remove("_skill_provenance");
None
} else {
finalize_skill_provenance(
&mut text.metadata,
&text.source,
&selector_metadata,
selector.raw,
&content,
)
};
Ok(ReadResourceOutput {
content,
metadata: text.metadata,
truncated,
request_complete,
provenance,
snapshot,
})
}
fn split_read_target<'a>(&self, requested: &'a str) -> anyhow::Result<(&'a str, ReadSelector)> {
if requested.starts_with("http://") || requested.starts_with("https://") {
return Ok((requested, ReadSelector::default()));
}
if requested.starts_with("skill://") {
let split = split_selector_suffix(requested)?;
if split.selector.lines.is_some() {
anyhow::bail!(
"skill resources do not support line selectors; skills and references are always read in full"
);
}
return Ok((split.target, split.selector));
}
if requested.contains("://") {
let split = split_selector_suffix(requested)?;
return Ok((split.target, split.selector));
}
if self.resolve_existing_for_read(requested).is_ok() {
return Ok((requested, ReadSelector::default()));
}
let split = split_selector_suffix(requested)?;
if split.selector.is_empty() {
return Ok((requested, ReadSelector::default()));
}
Ok((split.target, split.selector))
}
fn resolve_read_text(&self, target: &str) -> anyhow::Result<ReadText> {
if target.starts_with("http://") || target.starts_with("https://") {
anyhow::bail!("read does not support HTTP(S) URLs; use web open with url instead");
}
if let Some(resource) = target.strip_prefix("skill://") {
if resource.is_empty() {
anyhow::bail!("skill:// resource requires a skill name");
}
let (name, reference) = resource
.split_once('/')
.map_or((resource, None), |(name, reference)| {
(name, Some(reference))
});
if name.is_empty() {
anyhow::bail!("skill:// resource requires a skill name");
}
let result = if let Some(reference) = reference {
self.skills.load_skill_reference(name, reference)?
} else {
self.skills.load_skill_markdown(name)?
};
let source = if let Some(reference) = result.reference.as_deref() {
format!("skill://{name}/{reference}")
} else {
format!("skill://{name}")
};
return Ok(ReadText {
source,
kind: result.kind,
content: result.content,
metadata: result.metadata,
truncated: false,
});
}
if target.contains("://") {
anyhow::bail!(
"unsupported read resource scheme; only local files and skill:// are supported"
);
}
self.read_file_text(target)
}
fn read_file_text(&self, requested_path: &str) -> anyhow::Result<ReadText> {
let path = self.resolve_existing_for_read(requested_path)?;
let file = open_regular_file(&path, false)?;
let content = String::from_utf8(read_regular_file_bounded(&file, FILE_READ_MAX_BYTES)?)?;
let byte_len = content.len();
let mut metadata = Map::new();
metadata.insert(meta::PATH.to_string(), json!(path));
metadata.insert(meta::BYTES.to_string(), json!(byte_len));
Ok(ReadText {
source: requested_path.to_string(),
kind: "file",
content,
metadata,
truncated: false,
})
}
}
#[derive(Debug, Clone)]
pub(super) struct ReadText {
pub(super) source: String,
pub(super) kind: &'static str,
pub(super) content: String,
pub(super) metadata: Map<String, Value>,
pub(super) truncated: bool,
}
#[derive(Debug, Clone)]
pub(super) struct ReadResourceOutput {
pub(super) content: String,
pub(super) metadata: Map<String, Value>,
pub(super) truncated: bool,
pub(super) request_complete: bool,
pub(super) provenance: Option<SkillReadProvenance>,
pub(super) snapshot: Option<(PathBuf, String, Vec<usize>)>,
}
#[derive(Debug)]
pub(super) struct FileLineSelection {
pub(super) start: usize,
pub(super) end: usize,
pub(super) total_lines: Option<usize>,
pub(super) truncated: bool,
}
#[derive(Debug, Clone)]
pub(super) struct FormattedHashlineRead {
pub(super) content: String,
pub(super) seen_lines: Vec<usize>,
pub(super) truncated: bool,
}
pub(super) fn metadata_path(metadata: &Map<String, Value>) -> anyhow::Result<PathBuf> {
let value = metadata
.get(meta::PATH)
.ok_or_else(|| anyhow::anyhow!("file read metadata missing path"))?;
let path = serde_json::from_value::<PathBuf>(value.clone())?;
Ok(path)
}
pub(super) fn select_file_lines(
normalized_text: &str,
selector: ReadSelector,
) -> FileLineSelection {
let total = count_lines_lossless(normalized_text);
let (start, end_limit) = match selector.lines {
Some(LineSelector::Single(line)) => (line.saturating_sub(1), line),
Some(LineSelector::Range { start, end }) => (start.saturating_sub(1), end),
Some(LineSelector::FromStart { end }) => (0, end),
Some(LineSelector::FromLine { start }) => (start.saturating_sub(1), total),
Some(LineSelector::Count { start, count }) => {
let zero = start.saturating_sub(1);
(zero, zero.saturating_add(count))
}
None => (0, FILE_READ_DEFAULT_LINES),
};
let end = end_limit.min(total);
let truncated = end < total || (start > 0 && total > 0);
let total_lines = if selector.lines.is_some() || !truncated {
Some(total)
} else {
None
};
FileLineSelection {
start,
end,
total_lines,
truncated,
}
}
pub(super) fn format_hashline_read(
path: &Path,
normalized_text: &str,
selection: &FileLineSelection,
) -> anyhow::Result<FormattedHashlineRead> {
let tag = compute_file_hash(normalized_text);
let path_text = path.to_string_lossy();
let header = format_hashline_header(&path_text, &tag);
let capacity = FILE_READ_MAX_BYTES as usize;
if header.len() > capacity {
anyhow::bail!("hashline read header exceeds {FILE_READ_MAX_BYTES} byte output limit");
}
let mut content = header;
let mut seen_lines = Vec::new();
let mut truncated = false;
for (index, line) in normalized_text.split_inclusive('\n').enumerate() {
if index < selection.start {
continue;
}
if index >= selection.end {
break;
}
let line = line.strip_suffix('\n').unwrap_or(line);
let line_number = index + 1;
let addition_len = 1 + decimal_digits(line_number) + 1 + line.len();
if content.len().saturating_add(addition_len) > capacity {
truncated = true;
break;
}
write!(&mut content, "\n{line_number}:{line}")
.map_err(|_| anyhow::anyhow!("failed to format hashline read"))?;
seen_lines.push(line_number);
}
Ok(FormattedHashlineRead {
content,
seen_lines,
truncated,
})
}
pub(super) fn decimal_digits(mut value: usize) -> usize {
let mut digits = 1;
while value >= 10 {
value /= 10;
digits += 1;
}
digits
}
pub(super) fn count_lines_lossless(text: &str) -> usize {
text.split_inclusive('\n').count()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MultiReadAppend {
Complete,
Truncated { body_bytes: usize },
Omitted,
}
pub(super) fn append_multi_read_section(
output: &mut String,
requested_path: &str,
body: &str,
has_remaining_resources: bool,
) -> MultiReadAppend {
let separator = if output.is_empty() { "" } else { "\n\n" };
let section = format!("{separator}--- FILE: {requested_path} ---\n{body}");
let capacity = FILE_READ_MAX_BYTES as usize;
let reserved_notice_bytes = if has_remaining_resources {
aggregate_truncation_notice(true)
.len()
.max(aggregate_omission_notice(true).len())
} else {
0
};
if output.len() + section.len() + reserved_notice_bytes <= capacity {
output.push_str(§ion);
return MultiReadAppend::Complete;
}
if has_remaining_resources {
let notice = aggregate_truncation_notice(true);
let available = capacity
.saturating_sub(output.len())
.saturating_sub(notice.len());
let header_len = separator.len() + "--- FILE: ---\n".len() + requested_path.len();
if available > header_len {
let before = output.len();
append_prefix_at_char_boundary(output, §ion, available);
let body_bytes = output.len() - before - header_len;
append_aggregate_notice(output, ¬ice);
return MultiReadAppend::Truncated { body_bytes };
}
}
let notice = aggregate_omission_notice(!output.is_empty());
append_aggregate_notice(output, ¬ice);
MultiReadAppend::Omitted
}
pub(super) fn aggregate_omission_notice(has_output: bool) -> String {
let separator = if has_output { "\n\n" } else { "" };
format!(
"{separator}--- READ OMITTED ---\nERROR: aggregate read output limit is {FILE_READ_MAX_BYTES} bytes; this resource and remaining resources were omitted"
)
}
pub(super) fn aggregate_truncation_notice(has_output: bool) -> String {
let separator = if has_output { "\n\n" } else { "" };
format!(
"{separator}--- READ TRUNCATED ---\nERROR: aggregate read output limit is {FILE_READ_MAX_BYTES} bytes; this resource was truncated and remaining resources were omitted"
)
}
pub(super) fn append_prefix_at_char_boundary(output: &mut String, text: &str, max_bytes: usize) {
let mut end = max_bytes.min(text.len());
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
output.push_str(&text[..end]);
}
pub(super) fn append_aggregate_notice(output: &mut String, notice: &str) {
let capacity = FILE_READ_MAX_BYTES as usize;
if output.len() + notice.len() > capacity {
output.clear();
output.push_str("ERROR: requested resources were omitted");
return;
}
output.push_str(notice);
}