Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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,
None,
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