use super::{
ToolResult, ToolResultDisplay,
args::{FILE_READ_MAX_BYTES, FILE_READ_MAX_FILES, FILE_WRITE_MAX_BYTES, ReadArgs, WriteArgs},
contract::{metadata_key as meta, tool_name},
skill_provenance::digest_hex,
};
use crate::{
cancellation::AgentCancellation,
checkpoints::SnapshotTool,
persistence::{CrossProcessFileLock, atomic_write},
};
use serde_json::{Value, json};
use std::{
fs,
path::{Path, PathBuf},
sync::Mutex,
};
mod mutation;
mod path_policy;
mod path_suggestions;
mod read_format;
pub(super) use mutation::MutationContext;
pub(super) use path_policy::{ExistingPathPolicy, PathContext, resolve_existing_path_with_policy};
use read_format::{MultiReadAppend, append_multi_read_section};
pub(super) struct ReadSnapshot {
pub(super) path: PathBuf,
pub(super) text: String,
pub(super) seen_lines: Vec<usize>,
}
pub(super) struct ReadContext<'a> {
pub(super) paths: PathContext<'a>,
pub(super) settings: &'a crate::config::ReadToolSettings,
pub(super) hashline_snapshots: &'a Mutex<super::hash_edit::HashlineSnapshotStore>,
pub(super) pending_snapshots: Option<&'a Mutex<Vec<ReadSnapshot>>>,
pub(super) skills: super::skill::SkillContext<'a>,
}
pub(super) struct WriteContext<'a> {
pub(super) paths: PathContext<'a>,
pub(super) settings: &'a crate::config::WriteToolSettings,
pub(super) mutation: MutationContext<'a>,
pub(super) humanize_protection: &'a Option<crate::protection::humanize::HumanizeProtection>,
}
impl ReadContext<'_> {
pub(super) fn read(
&self,
args: ReadArgs,
cancellation: &AgentCancellation,
) -> anyhow::Result<ToolResult> {
if !args.is_multi_file() {
let requested_path = args
.requested_paths()
.first()
.ok_or_else(|| anyhow::anyhow!("read requires paths"))?;
let mut resource = self.read_one_resource(requested_path, cancellation)?;
let delivered_bytes = resource.content.len();
self.register_read_snapshot(&mut resource, delivered_bytes)?;
let mut metadata = resource.metadata;
if let Some(provenance) = resource.provenance {
metadata.insert("skill_reads".to_string(), json!([provenance]));
}
metadata.insert(meta::BYTE_LIMIT.to_string(), json!(FILE_READ_MAX_BYTES));
metadata.insert(
"structured_result".to_string(),
json!({
"files": [{
"path": requested_path, "text": resource.content,
"success": true,
"complete": !resource.truncated,
"request_complete": resource.request_complete,
}],
"complete": !resource.truncated,
"request_complete": resource.request_complete,
"outcome": if resource.request_complete { "complete" } else { "partial" },
}),
);
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;
let mut skill_reads = Vec::new();
let mut files = Vec::new();
for (index, requested_path) in requested_paths.iter().enumerate() {
match self.read_one_resource(requested_path, cancellation) {
Ok(mut resource) => {
let appended = append_multi_read_section(
&mut output,
requested_path,
&resource.content,
index + 1 < requested_paths.len(),
);
match appended {
MultiReadAppend::Complete => {
let delivered_bytes = resource.content.len();
self.register_read_snapshot(&mut resource, delivered_bytes)?;
}
MultiReadAppend::Truncated { body_bytes } => {
self.register_read_snapshot(&mut resource, body_bytes)?;
}
MultiReadAppend::Omitted => {}
}
let body = resource.content;
let provenance = resource.provenance;
let mut summary = resource.metadata;
summary.insert("requested_path".to_string(), json!(requested_path));
match appended {
MultiReadAppend::Complete => {
files.push(json!({"path": requested_path, "text": body,
"success": true, "complete": !resource.truncated,
"request_complete": resource.request_complete}));
succeeded += 1;
truncated |= resource.truncated;
summary.insert("success".to_string(), json!(true));
summaries.push(Value::Object(summary));
if let Some(mut provenance) = provenance {
provenance.start = output.len() - body.len();
provenance.end = output.len();
provenance.sha256 = digest_hex(
&output.as_bytes()[provenance.start..provenance.end],
);
skill_reads.push(provenance);
}
}
MultiReadAppend::Truncated { body_bytes } => {
files.push(json!({"path": requested_path, "text": &body[..body_bytes],
"success": true, "complete": false, "request_complete": false,
"error": "aggregate read output limit exceeded"}));
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));
summary.insert("request_complete".to_string(), json!(false));
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 => {
files.push(
json!({"path": requested_path, "text": "", "success": false,
"omitted": true, "complete": false, "request_complete": false,
"error": "aggregate read output limit exceeded"}),
);
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("request_complete".to_string(), json!(false));
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;
files.push(json!({"path": requested_path, "text": "", "success": false,
"complete": false, "request_complete": false, "error": error.to_string()}));
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;
}
}
}
}
files.extend(requested_paths[files.len()..].iter().map(|path| {
json!({"path": path, "text": "", "success": false, "omitted": true,
"complete": false, "request_complete": false,
"error": "aggregate read output limit exceeded"})
}));
let request_complete = files.iter().all(|file| file["request_complete"] == true);
let outcome = if succeeded == 0 {
"failed"
} else if request_complete {
"complete"
} else {
"partial"
};
if failed > 0 || truncated || aggregate_truncated || skill_reads.len() > 8 {
skill_reads.clear();
}
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::BYTE_LIMIT): FILE_READ_MAX_BYTES,
"file_limit": FILE_READ_MAX_FILES,
(meta::TRUNCATED): truncated,
"aggregate_truncated": aggregate_truncated,
"results": summaries,
"skill_reads": skill_reads,
"structured_result": {
"files": files,
"complete": failed == 0 && !truncated && !aggregate_truncated,
"request_complete": request_complete,
"outcome": outcome,
},
}),
display: ToolResultDisplay::default(),
})
}
}
impl WriteContext<'_> {
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.mutation.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.mutation.pre_snapshot_bytes(&path)?;
let prose_check = if let Some(protection) = &self.humanize_protection {
let before = (|| -> anyhow::Result<String> {
match crate::persistence::open_regular_file(&path, false) {
Ok(file) => Ok(String::from_utf8(
crate::persistence::read_regular_file_bounded(
&file,
FILE_WRITE_MAX_BYTES as u64,
)?,
)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => Err(error.into()),
}
})();
Some(match before {
Ok(before) => protection.check_changes([(
path.as_path(),
before.as_str(),
args.content.as_str(),
)]),
Err(error) => protection.unavailable(error),
})
} else {
None
};
cancellation.check()?;
if let Some(result) = prose_check
.as_ref()
.and_then(|check| check.blocked_result(tool_name::WRITE))
{
return Ok(super::dispatch::FilesystemOutcome {
result,
paths: Vec::new(),
});
}
if let Some(parent) = path.parent() {
cancellation.check()?;
fs::create_dir_all(parent)?;
self.ensure_parent_inside_after_creation(
parent,
self.settings.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.mutation.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.mutation.record_file_snapshot(
SnapshotTool::WriteFile,
&path,
pre_snapshot.as_deref(),
Some(args.content.as_bytes()),
);
let mut outcome = 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],
};
if let Some(check) = prose_check {
outcome.result.metadata["humanize_protection"] = check.metadata;
}
Ok(outcome)
}
}