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, format_read_failure};
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 = match self.read_one_resource(requested_path, cancellation) {
Ok(resource) => resource,
Err(error) => {
cancellation.check()?;
let message = error.to_string();
let error_text = format_read_failure(requested_path, "error", &message);
let mut content = String::new();
if append_multi_read_section(&mut content, &error_text, 0)
== MultiReadAppend::Omitted
{
anyhow::bail!("read resource identifier exceeds the output limit");
}
return Ok(ToolResult {
tool_name: tool_name::READ.to_string(),
success: false,
metadata: json!({
"path": requested_path,
"structured_result": {
"files": [{"path": requested_path, "text": content,
"success": false, "complete": false,
"request_complete": false, "error": message}],
"complete": false, "request_complete": false, "outcome": "failed",
},
}),
content,
display: ToolResultDisplay::default(),
});
}
};
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 omitted_sections: Vec<_> = requested_paths
.iter()
.map(|path| {
format_read_failure(path, "omitted", "aggregate read output limit exceeded")
})
.collect();
let mut reserved_bytes = omitted_sections
.iter()
.map(|text| text.len() + 2)
.sum::<usize>();
anyhow::ensure!(
reserved_bytes <= FILE_READ_MAX_BYTES as usize,
"read resource identifiers exceed the aggregate output limit"
);
let mut output = String::new();
let mut summaries = Vec::with_capacity(requested_paths.len());
let mut succeeded = 0usize;
let mut failed = 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() {
reserved_bytes -= omitted_sections[index].len() + 2;
match self.read_one_resource(requested_path, cancellation) {
Ok(mut resource) => {
let appended =
append_multi_read_section(&mut output, &resource.content, reserved_bytes);
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 { text, .. } => {
files.push(json!({"path": requested_path, "text": text,
"success": true, "complete": false, "request_complete": false,
"error": "aggregate read output limit exceeded"}));
succeeded += 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));
break;
}
MultiReadAppend::Omitted => {
aggregate_truncated = true;
truncated = true;
break;
}
}
}
Err(error) => {
cancellation.check()?;
let message = error.to_string();
let error_text = format_read_failure(requested_path, "error", &message);
let appended =
append_multi_read_section(&mut output, &error_text, reserved_bytes);
let (text, clipped) = match appended {
MultiReadAppend::Complete => (error_text, false),
MultiReadAppend::Truncated { text, .. } => (text, true),
MultiReadAppend::Omitted => {
aggregate_truncated = true;
truncated = true;
break;
}
};
failed += 1;
files.push(
json!({"path": requested_path, "text": text, "success": false,
"complete": false, "request_complete": false, "error": message}),
);
summaries.push(json!({
"requested_path": requested_path,
"success": false,
"error": message,
}));
if clipped {
aggregate_truncated = true;
truncated = true;
break;
}
}
}
}
cancellation.check()?;
let omitted = requested_paths.len() - files.len();
for (path, text) in requested_paths[files.len()..]
.iter()
.zip(&omitted_sections[files.len()..])
{
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(text);
files.push(
json!({"path": path, "text": text, "success": false, "omitted": true,
"complete": false, "request_complete": false,
"error": "aggregate read output limit exceeded"}),
);
summaries.push(json!({"requested_path": path, "success": false,
"omitted": true, "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)
}
}