use super::{
ToolResult, ToolResultDisplay, ToolRuntime,
args::{
FILE_READ_DEFAULT_LINES, FILE_READ_MAX_BYTES, FILE_READ_MAX_FILES, FILE_READ_MAX_LINES,
FILE_WRITE_MAX_BYTES, ReadArgs, WriteArgs,
},
contract::{metadata_key as meta, tool_name},
github_resource::{GitHubResourceKind, fetch_github_resource},
hash_edit::{
format::{compute_file_hash, format_hashline_header},
normalize::normalize_for_snapshot_storage,
},
read_selector::{LineSelector, ReadSelector, split_selector_suffix},
url_fetch::{UrlFetchInput, fetch_url},
};
use crate::{
cancellation::AgentCancellation,
checkpoints::{
SnapshotEligibility, SnapshotTool, capture_file_snapshot, classify_snapshot_eligibility,
},
path_utils::lexical_normalize,
persistence::{CrossProcessFileLock, atomic_write},
};
use serde_json::{Map, Value, json};
use std::{
fmt::Write as _,
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
#[derive(Debug, Clone, Copy)]
pub(super) struct ExistingPathPolicy {
allow_absolute_paths: bool,
setting_name: &'static str,
}
impl ExistingPathPolicy {
pub(super) fn read(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.read.absolute_paths",
}
}
pub(super) fn view_image(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.view_image.absolute_paths",
}
}
pub(super) fn hash_edit(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.hash_edit.absolute_paths",
}
}
pub(super) fn grep(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.grep.absolute_paths",
}
}
pub(super) fn find(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.find.absolute_paths",
}
}
pub(super) fn list_files(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.list_files.absolute_paths",
}
}
pub(super) fn ast_grep(allow_absolute_paths: bool) -> Self {
Self {
allow_absolute_paths,
setting_name: "tools.ast_grep.absolute_paths",
}
}
}
impl ToolRuntime {
pub(super) fn resolve_existing_path(
&self,
path: &str,
policy: ExistingPathPolicy,
) -> anyhow::Result<PathBuf> {
let user_path = PathBuf::from(path);
let is_absolute = user_path.is_absolute();
let candidate = if is_absolute {
user_path
} else {
self.cwd.join(user_path)
};
let canonical = candidate.canonicalize()?;
if !is_absolute || !policy.allow_absolute_paths {
self.ensure_inside_with_setting(&canonical, policy.setting_name)?;
}
Ok(canonical)
}
pub(super) fn resolve_existing_for_read(&self, path: &str) -> anyhow::Result<PathBuf> {
self.resolve_existing_path(path, ExistingPathPolicy::read(self.read_absolute_paths))
}
pub(super) fn resolve_for_write(&self, path: &str) -> anyhow::Result<PathBuf> {
let user_path = PathBuf::from(path);
let is_absolute = user_path.is_absolute();
let allow_absolute = self.write_absolute_paths && is_absolute;
let candidate = if is_absolute {
user_path
} else {
self.cwd.join(user_path)
};
if let Ok(metadata) = fs::symlink_metadata(&candidate) {
if metadata.file_type().is_symlink() {
anyhow::bail!(
"write target '{}' must not be a symlink",
candidate.display()
);
}
let canonical = candidate.canonicalize()?;
if !allow_absolute {
self.ensure_inside_with_setting(&canonical, "tools.write.absolute_paths")?;
}
return Ok(canonical);
}
let normalized = lexical_normalize(&candidate);
if !allow_absolute && !normalized.starts_with(&self.cwd_canonical) {
anyhow::bail!(
"path '{}' escapes cwd '{}' (set tools.write.absolute_paths=true to allow absolute paths)",
normalized.display(),
self.cwd_canonical.display()
);
}
let parent = normalized
.parent()
.ok_or_else(|| anyhow::anyhow!("path has no parent"))?;
let existing_parent = nearest_existing_ancestor(parent)?;
let parent_canonical = existing_parent.canonicalize()?;
if !allow_absolute {
self.ensure_inside_with_setting(&parent_canonical, "tools.write.absolute_paths")?;
}
Ok(normalized)
}
pub(super) fn ensure_inside_with_setting(
&self,
canonical: &Path,
setting_name: &str,
) -> anyhow::Result<()> {
if !canonical.starts_with(&self.cwd_canonical) {
anyhow::bail!(
"path '{}' escapes cwd '{}' ({} does not allow this path)",
canonical.display(),
self.cwd_canonical.display(),
setting_name
);
}
Ok(())
}
fn ensure_parent_inside_after_creation(
&self,
parent: &Path,
allow_absolute_paths: bool,
) -> anyhow::Result<()> {
let canonical = parent.canonicalize()?;
if allow_absolute_paths {
return Ok(());
}
self.ensure_inside_with_setting(&canonical, "tools.write.absolute_paths")
}
pub(super) fn file_lock(&self, path: &Path) -> anyhow::Result<Arc<Mutex<()>>> {
let key = path
.canonicalize()
.unwrap_or_else(|_| lexical_normalize(path));
let mut locks = self
.mutation_locks
.lock()
.map_err(|_| anyhow::anyhow!("tool mutation lock registry mutex poisoned"))?;
Ok(locks
.entry(key)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone())
}
pub(super) fn read(
&self,
args: ReadArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
let offset = args.offset.unwrap_or(1);
let limit = args.limit.unwrap_or(FILE_READ_DEFAULT_LINES);
if !args.is_multi_file() {
let requested_path = args
.requested_paths()
.first()
.ok_or_else(|| anyhow::anyhow!("read requires paths"))?;
let resource = self.read_one_resource(requested_path, offset, limit, cancellation)?;
let mut metadata = resource.metadata;
metadata.insert(meta::OFFSET.to_string(), json!(offset));
metadata.insert(meta::LIMIT.to_string(), json!(limit));
metadata.insert(meta::BYTE_LIMIT.to_string(), json!(FILE_READ_MAX_BYTES));
metadata.insert(meta::LINE_LIMIT.to_string(), json!(FILE_READ_MAX_LINES));
return Ok(ToolResult {
tool_name: tool_name::READ.to_string(),
success: true,
content: resource.content,
metadata: Value::Object(metadata),
display: ToolResultDisplay::default(),
});
}
let requested_paths = args.requested_paths();
let mut output = String::new();
let mut summaries = Vec::with_capacity(requested_paths.len());
let mut succeeded = 0usize;
let mut failed = 0usize;
let mut omitted = 0usize;
let mut truncated = false;
let mut aggregate_truncated = false;
for (index, requested_path) in requested_paths.iter().enumerate() {
match self.read_one_resource(requested_path, offset, limit, cancellation) {
Ok(resource) => {
let body = resource.content;
let mut summary = resource.metadata;
summary.insert("requested_path".to_string(), json!(requested_path));
match append_multi_read_section(
&mut output,
requested_path,
&body,
index + 1 < requested_paths.len(),
) {
MultiReadAppend::Complete => {
succeeded += 1;
truncated |= resource.truncated;
summary.insert("success".to_string(), json!(true));
summaries.push(Value::Object(summary));
}
MultiReadAppend::Truncated => {
succeeded += 1;
omitted = requested_paths.len() - index - 1;
aggregate_truncated = true;
truncated = true;
summary.insert("success".to_string(), json!(true));
summary.insert("truncated".to_string(), json!(true));
summaries.push(Value::Object(summary));
summaries.extend(requested_paths[index + 1..].iter().map(|path| {
json!({
"requested_path": path,
"success": false,
"omitted": true,
"error": "aggregate read output limit exceeded",
})
}));
break;
}
MultiReadAppend::Omitted => {
aggregate_truncated = true;
truncated = true;
omitted = requested_paths.len() - index;
summary.insert("success".to_string(), json!(false));
summary.insert("omitted".to_string(), json!(true));
summary.insert(
"error".to_string(),
json!("aggregate read output limit exceeded"),
);
summaries.push(Value::Object(summary));
summaries.extend(requested_paths[index + 1..].iter().map(|path| {
json!({
"requested_path": path,
"success": false,
"omitted": true,
"error": "aggregate read output limit exceeded",
})
}));
break;
}
}
}
Err(error) => {
failed += 1;
summaries.push(json!({
"requested_path": requested_path,
"success": false,
"error": error.to_string(),
}));
let error_text = format!("ERROR: {error}");
if append_multi_read_section(
&mut output,
requested_path,
&error_text,
index + 1 < requested_paths.len(),
) != MultiReadAppend::Complete
{
aggregate_truncated = true;
truncated = true;
omitted = requested_paths.len().saturating_sub(index + 1);
summaries.extend(requested_paths[index + 1..].iter().map(|path| {
json!({
"requested_path": path,
"success": false,
"omitted": true,
"error": "aggregate read output limit exceeded",
})
}));
break;
}
}
}
}
Ok(ToolResult {
tool_name: tool_name::READ.to_string(),
success: succeeded > 0,
content: output,
metadata: json!({
(meta::PATHS): requested_paths,
(meta::FILES): requested_paths.len(),
(meta::SUCCEEDED): succeeded,
(meta::FAILED): failed,
"omitted": omitted,
(meta::OFFSET): offset,
(meta::LIMIT): limit,
(meta::BYTE_LIMIT): FILE_READ_MAX_BYTES,
(meta::LINE_LIMIT): FILE_READ_MAX_LINES,
"file_limit": FILE_READ_MAX_FILES,
(meta::TRUNCATED): truncated,
"aggregate_truncated": aggregate_truncated,
"results": summaries,
}),
display: ToolResultDisplay::default(),
})
}
fn read_one_resource(
&self,
requested: &str,
offset: usize,
limit: usize,
cancellation: &AgentCancellation,
) -> anyhow::Result<ReadResourceOutput> {
cancellation.check()?;
let (target, selector) = self.split_read_target(requested)?;
let mut text = self.resolve_read_text(target, cancellation)?;
let selector_used = !selector.is_empty();
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) = if is_hashline_file {
let selection = select_file_lines(&text.content, offset, limit, 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,
)
} else if selector_used {
selector.apply(&text.content)
} else if text.kind == "file" {
apply_legacy_slice(&text.content, offset, limit)
} else {
selector.apply(&text.content)
};
if is_hashline_file {
let path = metadata_path(&text.metadata)?;
let seen_lines = hashline_seen_lines.unwrap_or_default();
let normalized = std::mem::take(&mut text.content);
let tag = self
.hashline_snapshots
.lock()
.map_err(|_| anyhow::anyhow!("hashline snapshot store mutex poisoned"))?
.record(&path, normalized, seen_lines.clone());
text.metadata.insert("hashline_tag".to_string(), json!(tag));
text.metadata
.insert("hashline_seen_lines".to_string(), json!(seen_lines));
}
text.metadata
.insert(meta::TOTAL_LINES.to_string(), json!(total_lines));
text.metadata.insert(
meta::TRUNCATED.to_string(),
json!(text.truncated || selector_truncated),
);
text.metadata
.insert("source".to_string(), json!(text.source));
text.metadata
.insert(meta::KIND.to_string(), json!(text.kind));
text.metadata
.insert("selector".to_string(), selector.metadata());
text.metadata.insert("raw".to_string(), json!(selector.raw));
if selector_used && (offset != 1 || limit != FILE_READ_DEFAULT_LINES) {
text.metadata
.insert("legacy_slice_ignored".to_string(), json!(true));
}
Ok(ReadResourceOutput {
content,
metadata: text.metadata,
truncated: text.truncated || selector_truncated,
})
}
fn split_read_target<'a>(&self, requested: &'a str) -> anyhow::Result<(&'a str, ReadSelector)> {
if requested.starts_with("http://") || requested.starts_with("https://") {
if let Ok(parsed) = reqwest::Url::parse(requested)
&& parsed.port().is_some()
&& parsed.path() == "/"
&& parsed.query().is_none()
{
return Ok((requested, ReadSelector::default()));
}
let split = split_selector_suffix(requested)?;
return Ok((split.target, split.selector));
}
if is_url_or_scheme(requested) {
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,
cancellation: &AgentCancellation,
) -> anyhow::Result<ReadText> {
if target.starts_with("http://") || target.starts_with("https://") {
return self.read_url(target, cancellation);
}
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.load_skill_reference(name, reference)?
} else {
self.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 let Some(id) = target.strip_prefix("session://") {
return self.read_session(id);
}
if let Some(id) = target.strip_prefix("issue://") {
let content = fetch_github_resource(GitHubResourceKind::Issue, id, cancellation)?;
let mut metadata = Map::new();
metadata.insert("issue".to_string(), json!(id));
metadata.insert(meta::BYTES.to_string(), json!(content.len()));
return Ok(ReadText {
source: format!("issue://{id}"),
kind: "issue",
content,
metadata,
truncated: false,
});
}
if let Some(id) = target.strip_prefix("pr://") {
let content = fetch_github_resource(GitHubResourceKind::Pr, id, cancellation)?;
let mut metadata = Map::new();
metadata.insert("pr".to_string(), json!(id));
metadata.insert(meta::BYTES.to_string(), json!(content.len()));
return Ok(ReadText {
source: format!("pr://{id}"),
kind: "pr",
content,
metadata,
truncated: false,
});
}
if target.contains("://") {
anyhow::bail!("unsupported read resource scheme");
}
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 byte_len = fs::metadata(&path)?.len();
if byte_len > FILE_READ_MAX_BYTES {
anyhow::bail!("file is {byte_len} bytes; read limit is {FILE_READ_MAX_BYTES} bytes");
}
let content = fs::read_to_string(&path)?;
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,
})
}
fn read_session(&self, id: &str) -> anyhow::Result<ReadText> {
if id.is_empty() {
anyhow::bail!("session:// resource requires a session id");
}
let session = self.session_manager.open(id.to_string())?;
let path = session.path();
let byte_len = fs::metadata(path)?.len();
if byte_len > FILE_READ_MAX_BYTES {
anyhow::bail!("session is {byte_len} bytes; read limit is {FILE_READ_MAX_BYTES} bytes");
}
let content = fs::read_to_string(path)?;
let mut metadata = Map::new();
metadata.insert("session_id".to_string(), json!(session.id()));
metadata.insert(meta::BYTES.to_string(), json!(byte_len));
metadata.insert("records".to_string(), json!(content.lines().count()));
Ok(ReadText {
source: format!("session://{}", session.id()),
kind: "session",
content,
metadata,
truncated: false,
})
}
fn read_url(&self, url: &str, cancellation: &AgentCancellation) -> anyhow::Result<ReadText> {
let result = fetch_url(
UrlFetchInput {
url: url.to_string(),
max_tokens: None,
}
.validate()?,
cancellation,
)?;
if !result.success {
anyhow::bail!(result.content);
}
let mut metadata = Map::new();
if let Some(object) = result.metadata.as_object() {
metadata.extend(object.clone());
}
Ok(ReadText {
source: url.to_string(),
kind: "url",
content: result.content,
truncated: result
.metadata
.get(meta::TRUNCATED)
.and_then(Value::as_bool)
.unwrap_or(false),
metadata,
})
}
#[cfg(test)]
pub(super) fn write_file(
&self,
args: WriteArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
self.write_file_outcome(args, cancellation)
.map(|outcome| outcome.result)
}
pub(super) fn write_file_outcome(
&self,
args: WriteArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<super::dispatch::FilesystemOutcome> {
self.write_file_with(args, cancellation, atomic_write)
}
fn write_file_with(
&self,
args: WriteArgs,
cancellation: &AgentCancellation,
write: impl FnOnce(&Path, &[u8]) -> anyhow::Result<()>,
) -> anyhow::Result<super::dispatch::FilesystemOutcome> {
cancellation.check()?;
if args.content.len() > FILE_WRITE_MAX_BYTES {
anyhow::bail!(
"write content is {} bytes; limit is {FILE_WRITE_MAX_BYTES} bytes",
args.content.len()
);
}
let path = self.resolve_for_write(&args.path)?;
let lock = self.file_lock(&path)?;
let _guard = lock
.lock()
.map_err(|_| anyhow::anyhow!("file mutation lock poisoned for '{}'", path.display()))?;
let _cross_process_guard = CrossProcessFileLock::acquire(&path)?;
let pre_snapshot = self.pre_snapshot_bytes(&path)?;
if let Some(parent) = path.parent() {
cancellation.check()?;
fs::create_dir_all(parent)?;
self.ensure_parent_inside_after_creation(
parent,
self.write_absolute_paths && PathBuf::from(&args.path).is_absolute(),
)?;
}
cancellation.check()?;
match write(&path, args.content.as_bytes()) {
Ok(()) => {}
Err(error)
if error
.downcast_ref::<crate::persistence::AtomicWriteCommittedButUndurable>()
.is_some() =>
{
self.record_file_snapshot(
SnapshotTool::WriteFile,
&path,
pre_snapshot.as_deref(),
Some(args.content.as_bytes()),
);
return Ok(super::dispatch::FilesystemOutcome {
result: ToolResult {
tool_name: tool_name::WRITE.to_string(),
success: false,
content: error.to_string(),
metadata: json!({
(meta::PATH): path,
(meta::BYTES): args.content.len(),
"outcome": "committed_but_undurable",
}),
display: ToolResultDisplay::default(),
},
paths: vec![path],
});
}
Err(error) => return Err(error),
}
self.record_file_snapshot(
SnapshotTool::WriteFile,
&path,
pre_snapshot.as_deref(),
Some(args.content.as_bytes()),
);
Ok(super::dispatch::FilesystemOutcome {
result: ToolResult {
tool_name: tool_name::WRITE.to_string(),
success: true,
content: format!("wrote {} bytes", args.content.len()),
metadata: json!({(meta::PATH): path, (meta::BYTES): args.content.len()}),
display: ToolResultDisplay::default(),
},
paths: vec![path],
})
}
fn snapshot_eligible(&self, path: &Path) -> bool {
let Some(context) = self.checkpoint_context() else {
return false;
};
matches!(
classify_snapshot_eligibility(path, &self.cwd_canonical, &context.paths),
SnapshotEligibility::Eligible
)
}
pub(super) fn pre_snapshot_bytes(&self, path: &Path) -> anyhow::Result<Option<Vec<u8>>> {
if !path.exists() || !self.snapshot_eligible(path) {
return Ok(None);
}
Ok(Some(fs::read(path)?))
}
pub(super) fn record_file_snapshot(
&self,
tool: SnapshotTool,
path: &Path,
pre: Option<&[u8]>,
post: Option<&[u8]>,
) {
let Some(context) = self.checkpoint_context() else {
return;
};
if let Err(error) =
capture_file_snapshot(&context, &self.cwd_canonical, tool, path, pre, post)
{
eprintln!(
"warning: checkpoint snapshot failed: {}",
crate::output::redact_sensitive_text(&error.to_string())
);
}
}
}
#[derive(Debug, Clone)]
struct ReadText {
source: String,
kind: &'static str,
content: String,
metadata: Map<String, Value>,
truncated: bool,
}
#[derive(Debug, Clone)]
struct ReadResourceOutput {
content: String,
metadata: Map<String, Value>,
truncated: bool,
}
#[derive(Debug)]
struct FileLineSelection {
start: usize,
end: usize,
total_lines: Option<usize>,
truncated: bool,
}
#[derive(Debug, Clone)]
struct FormattedHashlineRead {
content: String,
seen_lines: Vec<usize>,
truncated: bool,
}
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)
}
fn select_file_lines(
normalized_text: &str,
offset: usize,
limit: usize,
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 => {
let start = offset.saturating_sub(1);
(start, start.saturating_add(limit))
}
};
let end = end_limit.min(total);
let truncated = end < total;
let total_lines = if selector.lines.is_some() || !truncated {
Some(total)
} else {
None
};
FileLineSelection {
start,
end,
total_lines,
truncated,
}
}
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,
})
}
fn decimal_digits(mut value: usize) -> usize {
let mut digits = 1;
while value >= 10 {
value /= 10;
digits += 1;
}
digits
}
fn count_lines_lossless(text: &str) -> usize {
text.split_inclusive('\n').count()
}
fn is_url_or_scheme(target: &str) -> bool {
target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with("skill://")
|| target.starts_with("session://")
|| target.starts_with("issue://")
|| target.starts_with("pr://")
}
fn apply_legacy_slice(text: &str, offset: usize, limit: usize) -> (String, Option<usize>, bool) {
let start = offset.saturating_sub(1);
let end = start.saturating_add(limit);
let mut selected = Vec::new();
let mut total_lines = Some(0usize);
let mut truncated = false;
for (index, line) in text.lines().enumerate() {
if index >= end {
truncated = true;
total_lines = None;
break;
}
if index >= start {
selected.push(line.to_string());
}
total_lines = Some(index + 1);
}
(selected.join("\n"), total_lines, truncated)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MultiReadAppend {
Complete,
Truncated,
Omitted,
}
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 {
append_prefix_at_char_boundary(output, §ion, available);
append_aggregate_notice(output, ¬ice);
return MultiReadAppend::Truncated;
}
}
let notice = aggregate_omission_notice(!output.is_empty());
append_aggregate_notice(output, ¬ice);
MultiReadAppend::Omitted
}
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"
)
}
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"
)
}
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]);
}
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);
}
fn nearest_existing_ancestor(path: &Path) -> anyhow::Result<PathBuf> {
let mut cursor = path;
loop {
if cursor.exists() {
return Ok(cursor.to_path_buf());
}
cursor = cursor
.parent()
.ok_or_else(|| anyhow::anyhow!("no existing ancestor for '{}'", path.display()))?;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
checkpoints::{CheckpointStore, SnapshotContext},
config::McPaths,
tools::{
args::{ReadArgs, WriteArgs},
hash_edit::format::compute_file_hash,
},
};
use std::{path::Path, thread, time::Duration};
fn runtime_with_checkpoints(dir: &Path, mc_root: &Path) -> ToolRuntime {
let runtime = ToolRuntime::new(dir).expect("runtime");
let paths = McPaths::from_root(mc_root.to_path_buf());
runtime
.set_checkpoint_context(Some(SnapshotContext {
store: CheckpointStore::from_paths(&paths),
paths,
session_id: "session".to_string(),
user_turn: 1,
}))
.unwrap();
runtime
}
#[test]
fn read_file_emits_hashline_header_numbered_rows_and_snapshot_seen_lines() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.txt");
fs::write(&path, "one\ntwo \nthree\n").unwrap();
let path = path.canonicalize().unwrap();
let runtime = ToolRuntime::new(dir.path()).expect("runtime");
let result = runtime
.read(
ReadArgs {
path: Some("file.txt".to_string()),
paths: None,
offset: None,
limit: None,
},
&AgentCancellation::default(),
)
.unwrap();
let tag = compute_file_hash("one\ntwo \nthree\n");
assert_eq!(
result.content,
format!("[{}#{}]\n1:one\n2:two \n3:three", path.display(), tag)
);
let store = runtime.hashline_snapshots.lock().unwrap();
let snapshot = store.find_by_hash(&path, &tag).unwrap();
assert_eq!(snapshot.text, "one\ntwo \nthree\n");
assert_eq!(
snapshot.seen_lines,
std::collections::HashSet::from([1, 2, 3])
);
}
#[test]
fn read_file_partial_hashline_records_only_displayed_lines_for_full_file_tag() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.txt");
fs::write(&path, "one\ntwo\nthree\nfour\n").unwrap();
let path = path.canonicalize().unwrap();
let runtime = ToolRuntime::new(dir.path()).expect("runtime");
let result = runtime
.read(
ReadArgs {
path: Some("file.txt:2-3".to_string()),
paths: None,
offset: None,
limit: None,
},
&AgentCancellation::default(),
)
.unwrap();
let tag = compute_file_hash("one\ntwo\nthree\nfour\n");
assert_eq!(
result.content,
format!("[{}#{}]\n2:two\n3:three", path.display(), tag)
);
let store = runtime.hashline_snapshots.lock().unwrap();
let snapshot = store.find_by_hash(&path, &tag).unwrap();
assert_eq!(snapshot.text, "one\ntwo\nthree\nfour\n");
assert_eq!(snapshot.seen_lines, std::collections::HashSet::from([2, 3]));
}
#[test]
fn read_file_normalizes_bom_and_crlf_once_for_hashline_and_snapshot() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.txt");
fs::write(&path, "\u{FEFF}one\r\ntwo\rthree\r\nfour").unwrap();
let path = path.canonicalize().unwrap();
let runtime = ToolRuntime::new(dir.path()).expect("runtime");
let result = runtime
.read(
ReadArgs {
path: Some("file.txt:2-3".to_string()),
paths: None,
offset: None,
limit: None,
},
&AgentCancellation::default(),
)
.unwrap();
let normalized = "one\ntwo\nthree\nfour";
let tag = compute_file_hash(normalized);
assert_eq!(
result.content,
format!("[{}#{}]\n2:two\n3:three", path.display(), tag)
);
let store = runtime.hashline_snapshots.lock().unwrap();
let snapshot = store.find_by_hash(&path, &tag).unwrap();
assert_eq!(snapshot.text, normalized);
assert_eq!(snapshot.seen_lines, std::collections::HashSet::from([2, 3]));
}
#[test]
fn read_file_raw_selector_bypasses_hashline_and_snapshots() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join("file.txt"), "one\ntwo\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).expect("runtime");
let result = runtime
.read(
ReadArgs {
path: Some("file.txt:raw".to_string()),
paths: None,
offset: None,
limit: None,
},
&AgentCancellation::default(),
)
.unwrap();
assert_eq!(result.content, "one\ntwo\n");
assert_eq!(runtime.hashline_snapshots.lock().unwrap().len(), 0);
}
#[test]
fn review_fix_round_three_write_undurable_result_preserves_mutation() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.txt");
fs::write(&path, "old").unwrap();
let runtime = ToolRuntime::new(dir.path()).expect("runtime");
let result = runtime
.write_file_with(
WriteArgs {
path: "file.txt".to_string(),
content: "new".to_string(),
},
&AgentCancellation::default(),
|path, content| {
fs::write(path, content)?;
Err(
crate::persistence::AtomicWriteCommittedButUndurable::test_error(
path.to_path_buf(),
),
)
},
)
.unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "new");
assert!(!result.result.success);
assert_eq!(result.result.metadata["outcome"], "committed_but_undurable");
assert_eq!(result.paths, vec![path.canonicalize().unwrap()]);
}
#[test]
fn write_file_records_snapshot_for_existing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let mc = tempfile::tempdir().expect("mc");
fs::write(dir.path().join("file.txt"), "old").unwrap();
let runtime = runtime_with_checkpoints(dir.path(), mc.path());
runtime
.write_file(
WriteArgs {
path: "file.txt".to_string(),
content: "new".to_string(),
},
&AgentCancellation::default(),
)
.unwrap();
let store = runtime.checkpoint_context().unwrap().store;
let records = store.read_records("session").records;
assert_eq!(records.len(), 1);
assert!(records[0].event.pre.is_some());
assert!(records[0].event.post.is_some());
}
#[test]
fn subagent_checkpoint_updates_and_clears_preserve_parent_recording() {
let dir = tempfile::tempdir().expect("tempdir");
let mc = tempfile::tempdir().expect("mc");
let parent = runtime_with_checkpoints(dir.path(), mc.path());
let parent_context = parent.checkpoint_context().unwrap();
let child = parent
.clone_for_cwd_with_subagent_depth(dir.path(), 1)
.unwrap();
let child_context = SnapshotContext {
session_id: "child-session".to_string(),
user_turn: 7,
..parent_context.clone()
};
assert!(child.checkpoint_context().is_none());
for (stage, context) in [
("before-setup", None),
("active", Some(child_context)),
("cleared", None),
] {
child.set_checkpoint_context(context).unwrap();
for (name, runtime) in [("parent", &parent), ("child", &child)] {
let result = runtime
.write_file(
WriteArgs {
path: format!("{name}-{stage}.txt"),
content: format!("{name} {stage}"),
},
&AgentCancellation::default(),
)
.unwrap();
assert!(result.success);
}
}
let parent_records = parent_context.store.read_records("session").records;
assert_eq!(parent_records.len(), 3);
for (record, stage) in parent_records
.iter()
.zip(["before-setup", "active", "cleared"])
{
assert_eq!(record.event.session_id, "session");
assert_eq!(record.event.user_turn, 1);
assert_eq!(
record.event.relative_path,
Path::new(&format!("parent-{stage}.txt"))
);
assert!(record.event.post.is_some());
}
let child_records = parent_context.store.read_records("child-session").records;
assert_eq!(child_records.len(), 1);
assert_eq!(child_records[0].event.session_id, "child-session");
assert_eq!(child_records[0].event.user_turn, 7);
assert_eq!(
child_records[0].event.relative_path,
Path::new("child-active.txt")
);
assert!(child_records[0].event.post.is_some());
}
#[test]
fn write_file_records_created_file_snapshot() {
let dir = tempfile::tempdir().expect("tempdir");
let mc = tempfile::tempdir().expect("mc");
let runtime = runtime_with_checkpoints(dir.path(), mc.path());
runtime
.write_file(
WriteArgs {
path: "file.txt".to_string(),
content: "new".to_string(),
},
&AgentCancellation::default(),
)
.unwrap();
let records = runtime
.checkpoint_context()
.unwrap()
.store
.read_records("session")
.records;
assert_eq!(records.len(), 1);
assert!(records[0].event.pre.is_none());
assert!(records[0].event.post.is_some());
}
#[test]
fn write_file_does_not_snapshot_auth_json() {
let dir = tempfile::tempdir().expect("tempdir");
let mc = tempfile::tempdir().expect("mc");
let runtime = runtime_with_checkpoints(dir.path(), dir.path());
runtime
.write_file(
WriteArgs {
path: "auth.json".to_string(),
content: "topsecret-marker".to_string(),
},
&AgentCancellation::default(),
)
.unwrap();
let records = runtime
.checkpoint_context()
.unwrap()
.store
.read_records("session")
.records;
assert_eq!(records.len(), 1);
assert!(records[0].event.pre.is_none());
assert!(records[0].event.post.is_none());
assert!(
!fs::read_to_string(
runtime
.checkpoint_context()
.unwrap()
.paths
.checkpoints
.join("ledgers/session.jsonl")
)
.unwrap()
.contains("topsecret-marker")
);
drop(mc);
}
#[test]
fn write_waits_for_cross_process_file_lock() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.txt");
let held_lock = CrossProcessFileLock::acquire(&path).expect("hold lock");
let runtime = ToolRuntime::new(dir.path()).expect("runtime");
let writer = thread::spawn(move || {
runtime.write_file(
WriteArgs {
path: "file.txt".to_string(),
content: "new".to_string(),
},
&AgentCancellation::default(),
)
});
thread::sleep(Duration::from_millis(100));
assert!(
!path.exists(),
"write ran while cross-process lock was held"
);
drop(held_lock);
writer
.join()
.expect("writer thread")
.expect("write succeeds");
assert_eq!(fs::read_to_string(&path).expect("read written file"), "new");
}
}