Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
"crates/rho": "1.32.2",
"crates/rho-sdk": "1.17.2",
"crates/rho-providers": "0.18.1",
"crates/rho-tools": "0.12.6"
"crates/rho-tools": "0.13.0"
}
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/rho-tools/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rho-agent-tools"
version = "0.12.6"
version = "0.13.0"
edition = "2021"
rust-version = "1.92"
description = "Workspace coding tools and SDK tool adapters for Rho"
Expand Down
17 changes: 9 additions & 8 deletions crates/rho-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@
agent and adapters for registering them with `rho-sdk`. The crate is imported as
`rho_agent_tools`.

The built-in tools cover `read_file`, `write`, `edit` (hashline),
The built-in tools cover `read_file`, `write`, one selectable edit surface,
`list_dir`, `grep`, and `glob`, with shared diff generation and output limiting.
`read_file` returns UTF-8 sources as hashline views for `edit`.
`grep` content mode also mints chainable `[path#TAG]` headers on matching files
(via hashline) plus `N | text` match previews. Copy TAG and line numbers into
`edit`; do not treat preview bodies as hashline line text.
Successful `edit` results include a post-edit `[path#TAG]` numbered preview so a
follow-up edit can chain without an immediate re-read.
`coding_tools` constructs their SDK adapters, while `shell_tool` constructs the
`CodingToolOptions::edit_tool` selects `hashline` (`edit`), Codex-style
`apply_patch`, or `str_replace`; only that tool is registered.
`read_file` returns UTF-8 sources as hashline views. `grep` content mode also
mints `[path#TAG]` headers on matching files plus `N | text` match previews.
These snapshots let the default hashline `edit` chain tags and line numbers;
preview bodies are not exact source text. Successful mutations return bounded
post-edit snapshots, while full unified diffs stay in result metadata.
`coding_tools` constructs the SDK adapters, while `shell_tool` constructs the
platform shell adapter (`bash` on Linux and macOS, PowerShell on Windows).

The crate also exposes the application `Tool` contract and `RunCancellation` for
Expand Down
114 changes: 114 additions & 0 deletions crates/rho-tools/src/apply_patch/apply.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
//! Apply parsed Codex-style patch hunks to the filesystem.
//!
//! Pipeline:
//! 1. plan all changes and reject conflicts
//! 2. derive presentation output from the plan
//! 3. revalidate and commit in patch order
//! 4. roll back complete mutations and report any dirty targets

use std::path::PathBuf;

#[cfg(test)]
use std::sync::Arc;

use crate::{
file_mutation::FileMutationOutcome,
tool::{truncate, ToolError},
};

#[cfg(test)]
use crate::file_mutation::{AtomicCreateFaultInjector, RewriteFaultInjector};

use super::{
parser::Hunk,
planning::plan_hunks,
transaction::{commit_changes, CreateFault, RewriteFault},
};

pub(super) use super::model::FileChange;
pub(crate) use super::planning::{reject_symlink_entry, validate_hunk_paths};
#[cfg(test)]
pub(super) use super::transaction::rollback_one;

pub(crate) async fn apply_hunks(
hunks: Vec<Hunk>,
resolve_path: impl Fn(&str) -> Result<PathBuf, ToolError>,
display_path: impl Fn(&str) -> String,
max_output_bytes: usize,
) -> Result<FileMutationOutcome, ToolError> {
apply_hunks_inner(
hunks,
resolve_path,
display_path,
max_output_bytes,
/*rewrite_fault*/ None,
/*create_fault*/ None,
)
.await
}

#[cfg(test)]
pub(super) async fn apply_hunks_with_faults(
hunks: Vec<Hunk>,
resolve_path: impl Fn(&str) -> Result<PathBuf, ToolError>,
display_path: impl Fn(&str) -> String,
max_output_bytes: usize,
rewrite_fault: Option<Arc<dyn RewriteFaultInjector>>,
create_fault: Option<Arc<dyn AtomicCreateFaultInjector>>,
) -> Result<FileMutationOutcome, ToolError> {
apply_hunks_inner(
hunks,
resolve_path,
display_path,
max_output_bytes,
rewrite_fault,
create_fault,
)
.await
}

async fn apply_hunks_inner(
hunks: Vec<Hunk>,
resolve_path: impl Fn(&str) -> Result<PathBuf, ToolError>,
display_path: impl Fn(&str) -> String,
max_output_bytes: usize,
rewrite_fault: RewriteFault,
create_fault: CreateFault,
) -> Result<FileMutationOutcome, ToolError> {
let planned = plan_hunks(&hunks, &resolve_path, &display_path).await?;

let summary_lines = planned
.iter()
.map(FileChange::summary_line)
.collect::<Vec<_>>();
let diff = planned
.iter()
.map(FileChange::diff)
.collect::<Vec<_>>()
.join("\n\n");
let display_paths = planned
.iter()
.flat_map(FileChange::affected_display_paths)
.map(str::to_string)
.collect::<Vec<_>>();
let snapshots = planned
.iter()
.filter_map(FileChange::chain_snapshot)
.collect::<Vec<_>>();

commit_changes(&planned, rewrite_fault, create_fault).await?;

let mut content = format!(
"Success. Updated the following files:\n{}",
summary_lines.join("\n")
);
if !snapshots.is_empty() {
content.push_str("\n\n");
content.push_str(&snapshots.join("\n\n"));
}
Ok(FileMutationOutcome {
content: truncate(content, max_output_bytes),
display_paths,
diff,
})
}
188 changes: 188 additions & 0 deletions crates/rho-tools/src/apply_patch/content.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//! Content matching and replacement for update hunks.

use crate::{file_mutation::preferred_line_ending, tool::ToolError};

use super::{parser::UpdateFileChunk, seek_sequence::seek_sequence};

pub(super) fn derive_new_contents(
original_contents: &str,
path: &str,
chunks: &[UpdateFileChunk],
) -> Result<String, ToolError> {
let line_ending = preferred_line_ending(original_contents);
let source_lines = split_source_lines(original_contents);
let original_lines = source_lines
.iter()
.map(|line| line.content.clone())
.collect::<Vec<_>>();
let had_trailing_newline = source_lines
.last()
.is_some_and(|line| !line.ending.is_empty());
let replacements = compute_replacements(&original_lines, path, chunks)?;
Ok(apply_replacements(
&source_lines,
&replacements,
line_ending,
had_trailing_newline,
))
}

struct SourceLine {
content: String,
ending: String,
}

fn split_source_lines(contents: &str) -> Vec<SourceLine> {
let mut lines = Vec::new();
let mut start = 0;
let bytes = contents.as_bytes();
let mut index = 0;
while index < bytes.len() {
let ending_len = match bytes[index] {
b'\r' if bytes.get(index + 1) == Some(&b'\n') => 2,
b'\r' | b'\n' => 1,
_ => {
index += 1;
continue;
}
};
lines.push(SourceLine {
content: contents[start..index].to_string(),
ending: contents[index..index + ending_len].to_string(),
});
index += ending_len;
start = index;
}
if start < contents.len() {
lines.push(SourceLine {
content: contents[start..].to_string(),
ending: String::new(),
});
}
lines
}

fn compute_replacements(
original_lines: &[String],
path: &str,
chunks: &[UpdateFileChunk],
) -> Result<Vec<(usize, usize, Vec<String>)>, ToolError> {
let mut replacements = Vec::new();
let mut line_index = 0usize;
let mut min_next_start = 0usize;

for chunk in chunks {
if let Some(ctx_line) = &chunk.change_context {
let context = std::slice::from_ref(ctx_line);
if let Some(idx) =
seek_sequence(original_lines, context, line_index, /*eof*/ false)
{
line_index = idx + 1;
} else if seek_sequence(
original_lines,
context,
/*start*/ 0,
/*eof*/ false,
)
.is_some()
{
return Err(ToolError::Message(format!(
"patch chunks overlap or apply out of order in {path}"
)));
} else {
return Err(ToolError::Message(format!(
"Failed to find context '{ctx_line}' in {path}"
)));
}
}

if chunk.old_lines.is_empty() {
if line_index < min_next_start {
return Err(ToolError::Message(format!(
"patch chunks overlap or apply out of order in {path}"
)));
}
replacements.push((line_index, 0, chunk.new_lines.clone()));
min_next_start = line_index;
continue;
}

let mut pattern: &[String] = &chunk.old_lines;
let mut found = seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
let mut new_slice: &[String] = &chunk.new_lines;

if found.is_none() && pattern.last().is_some_and(String::is_empty) {
pattern = &pattern[..pattern.len() - 1];
if new_slice.last().is_some_and(String::is_empty) {
new_slice = &new_slice[..new_slice.len() - 1];
}
found = seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
}

if let Some(start_idx) = found {
if start_idx < min_next_start {
return Err(ToolError::Message(format!(
"patch chunks overlap or apply out of order in {path}"
)));
}
replacements.push((start_idx, pattern.len(), new_slice.to_vec()));
min_next_start = start_idx + pattern.len();
line_index = min_next_start;
} else {
return Err(ToolError::Message(format!(
"Failed to find expected lines in {path}:\n{}",
chunk.old_lines.join("\n")
)));
}
}

Ok(replacements)
}

enum OutputLine<'a> {
Original(&'a SourceLine),
Replacement(&'a str),
}

fn apply_replacements(
lines: &[SourceLine],
replacements: &[(usize, usize, Vec<String>)],
preferred_ending: &str,
trailing_newline: bool,
) -> String {
let mut output_lines = Vec::new();
let mut source_index = 0;
for (start, old_len, replacement) in replacements {
output_lines.extend(lines[source_index..*start].iter().map(OutputLine::Original));
output_lines.extend(
replacement
.iter()
.map(|line| OutputLine::Replacement(line.as_str())),
);
source_index = start + old_len;
}
output_lines.extend(lines[source_index..].iter().map(OutputLine::Original));

let mut output = String::new();
let last = output_lines.len().saturating_sub(1);
for (index, line) in output_lines.into_iter().enumerate() {
let has_following_line = index < last;
match line {
OutputLine::Original(line) => {
output.push_str(&line.content);
if has_following_line && line.ending.is_empty() {
output.push_str(preferred_ending);
} else {
output.push_str(&line.ending);
}
}
OutputLine::Replacement(line) => {
output.push_str(line);
if has_following_line || trailing_newline {
output.push_str(preferred_ending);
}
}
}
}
output
}
Loading
Loading