feat: project attached checkouts with scoped native AI operations
This commit is contained in:
@@ -24,6 +24,59 @@ Does not own:
|
||||
|
||||
A tool implementation must assume model input is untrusted. Permission policy, scope checks, output bounding, and redaction are part of the safety boundary, not optional UI behavior.
|
||||
|
||||
## Checkout-native processing
|
||||
|
||||
`execute_checkout_tool` (also exported through `tools::checkout`) accepts the
|
||||
original `WorkdirSessionRouter`, shared `Tracker`, route-bound alias/generation,
|
||||
logical target and provider validator. It does not create an attachment or router.
|
||||
The Host owns native publication and permission identity; provider scope and
|
||||
capabilities remain authoritative. `Create` uses the `Write` capability/identity.
|
||||
|
||||
| Operation | Arguments (only these keys accepted) | Target |
|
||||
| --- | --- | --- |
|
||||
| Read | `offset?`, `limit?` | Existing file; same numbered lines, defaults and byte bound as Tool Read |
|
||||
| Edit | `old_string`, `new_string`, `replace_all?` | Existing file; prior read/hash and provider validator required |
|
||||
| Write | `content` | Existing file only; prior read/hash and provider validator required |
|
||||
| Create | `path`, `content` | Bound parent directory; relative destination strictly below it; create-new, missing parents allowed |
|
||||
| Glob | `pattern`, `path?` | Bound directory; optional path relative beneath it |
|
||||
| Grep | `pattern`, `path?`, `glob?`, `type?`, `case_insensitive?`, `-B?`, `-A?`, `-C?`, `multiline?`, `output_mode?`, `head_limit?`, `offset?` | Bound directory; same provider search/options/rendering as Tool Grep |
|
||||
|
||||
Native arguments cannot override `file_path`, `target_workdir`, generation or
|
||||
validator. Absolute/parent-traversing directory-relative paths are rejected.
|
||||
Checkout file calls share Tool processing through an optional checked `FileTarget`
|
||||
and use `checkout_execute`, never a separate Worker-side filesystem engine.
|
||||
Read records the provider's full content hash **and total line count**, including
|
||||
partial reads, in the original alias/generation namespace. Normal and native
|
||||
observations/mutations interoperate. Only successful mutations update tracking.
|
||||
|
||||
Glob/Grep share normal request defaults/options and rendering through `SearchTarget`.
|
||||
Only native dispatch uses `CheckoutSearchRequest::new` and `checkout_search`.
|
||||
The constructor's empty scope layers/root output are refined by provider wrappers;
|
||||
Tools does not synthesize authority or parse/rebase rendered filenames. Ordinary
|
||||
Tool dispatch remains `session.glob`/`session.grep`, with its existing error mapping.
|
||||
|
||||
`CheckoutToolOutput.paths` contains typed checkout-root-relative search paths
|
||||
(from `GlobResult.paths` / `GrepResult.paths`) and the created path for Create.
|
||||
Rendered Grep text is never parsed to construct links. The optional validator is
|
||||
the provider's returned observation validator, unchanged. Create's result
|
||||
observation describes the bound parent target, while returned paths and tracker
|
||||
updates describe the destination. Search checks the bound observation before and
|
||||
after the bounded checkout provider search, but does **not** guarantee an atomic tree snapshot
|
||||
or prevent external edits to descendants. File validator/hash checks are inside
|
||||
the provider's checked execution boundary. Checked native provider conflicts and
|
||||
native generation/search fences use `ToolError::StructuredConflict` with code
|
||||
`checkout_stale`; ordinary Tool conflicts retain `ExecutionFailed`. Checked-only
|
||||
known provider refusals use `checkout_denied` (scope/capability/read-only policy),
|
||||
`checkout_invalid` (invalid arguments/paths/target kind/search syntax), or
|
||||
`checkout_unavailable` (closed session/unavailable capacity). A disappeared bound
|
||||
target is `checkout_stale`. These codes preserve known no-effect rejection for the
|
||||
Host; ordinary Tool error mappings are unchanged. Ambiguous transport/IO and
|
||||
operation failures remain `ExecutionFailed`, as does explicit provider
|
||||
`OutcomeUnknown`; a failure after mutation must not
|
||||
be automatically retried. A malformed post-mutation provider result is likewise
|
||||
reported explicitly as outcome unknown. Explicit mismatched Read/Glob/Grep results
|
||||
are readonly failures classified as `checkout_unavailable`, not stale or unknown.
|
||||
|
||||
## See also
|
||||
|
||||
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Native checkout entrypoint using the same processing as ordinary filesystem Tools.
|
||||
//!
|
||||
//! The Host binds alias, generation, path and validator from the checkout route; none
|
||||
//! can be overridden by Operation arguments. File reads/mutations dispatch through
|
||||
//! the provider's checked execution boundary. Search shares normal request and
|
||||
//! rendering processing but dispatches through checkout_search so provider scope
|
||||
//! layers and output rebasing are preserved; checks bracket the bound directory
|
||||
//! observation, not an atomic snapshot of the searched tree.
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::tool::{ToolError, ToolExecutionContext, ToolOutput};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use workdir::{WorkdirPath, WorkdirSessionCapability, WorkdirSessionRouter};
|
||||
|
||||
use crate::{ToolsError, Tracker, file_target::FileTarget};
|
||||
|
||||
pub struct CheckoutToolOutput {
|
||||
pub output: ToolOutput,
|
||||
pub paths: Vec<WorkdirPath>,
|
||||
pub validator: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Execute an exact named native Operation on a route-bound checkout target.
|
||||
/// `Create.path` and search `path` are relative to the bound directory; returned
|
||||
/// paths are typed checkout-root-relative provider paths, never parsed output.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_checkout_tool(
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
tracker: Tracker,
|
||||
alias: &str,
|
||||
generation: u64,
|
||||
path: WorkdirPath,
|
||||
validator: Vec<u8>,
|
||||
tool_name: &str,
|
||||
arguments: Value,
|
||||
ctx: ToolExecutionContext,
|
||||
) -> Result<CheckoutToolOutput, ToolError> {
|
||||
let (capability, allowed): (_, &[&str]) = match tool_name {
|
||||
"Read" => (WorkdirSessionCapability::Read, &["offset", "limit"]),
|
||||
"Edit" => (
|
||||
WorkdirSessionCapability::Edit,
|
||||
&["old_string", "new_string", "replace_all"],
|
||||
),
|
||||
"Write" => (WorkdirSessionCapability::Write, &["content"]),
|
||||
"Create" => (WorkdirSessionCapability::Write, &["path", "content"]),
|
||||
"Glob" => (WorkdirSessionCapability::Glob, &["pattern", "path"]),
|
||||
"Grep" => (
|
||||
WorkdirSessionCapability::Grep,
|
||||
&[
|
||||
"pattern",
|
||||
"path",
|
||||
"glob",
|
||||
"type",
|
||||
"case_insensitive",
|
||||
"-B",
|
||||
"-A",
|
||||
"-C",
|
||||
"multiline",
|
||||
"output_mode",
|
||||
"head_limit",
|
||||
"offset",
|
||||
],
|
||||
),
|
||||
_ => {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"unknown checkout Operation: {tool_name}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let mut arguments = arguments
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| ToolError::InvalidArgument("checkout arguments must be an object".into()))?;
|
||||
if let Some(key) = arguments
|
||||
.keys()
|
||||
.find(|key| !allowed.contains(&key.as_str()))
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"unknown or route override checkout argument: {key}"
|
||||
)));
|
||||
}
|
||||
// Do not rebuild a singleton router: this is the original router identity and
|
||||
// tracker namespace shared with normal Tool calls and its detach/reuse fence.
|
||||
let selected = crate::routing::resolve_session(&router, Some(alias), capability)?;
|
||||
if selected.generation != generation {
|
||||
return Err(stale_checkout(
|
||||
"checkout attachment generation changed; resolve the checkout again",
|
||||
));
|
||||
}
|
||||
// Checkout paths are always logical and cannot use Tool-mode absolute scope.
|
||||
let path = WorkdirPath::new(path.as_str()).map_err(ToolsError::from)?;
|
||||
let tracker = tracker.scoped_attachment(&selected.alias, selected.generation);
|
||||
let target = FileTarget {
|
||||
session: selected.session.clone(),
|
||||
path: path.clone(),
|
||||
validator: Some(validator.clone()),
|
||||
};
|
||||
match tool_name {
|
||||
"Read" | "Edit" | "Write" => {
|
||||
arguments.insert("file_path".into(), Value::String(path.to_string()));
|
||||
let arguments = Value::Object(arguments);
|
||||
match tool_name {
|
||||
"Read" => {
|
||||
crate::read::execute_read(target, tracker, decode(arguments, tool_name)?).await
|
||||
}
|
||||
"Edit" => {
|
||||
crate::edit::execute_edit(target, tracker, decode(arguments, tool_name)?, ctx)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
let params: crate::write::WriteParams = decode(arguments, tool_name)?;
|
||||
crate::write::execute_write(target, tracker, params.content, None, ctx).await
|
||||
}
|
||||
}
|
||||
}
|
||||
"Create" => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CreateParams {
|
||||
path: String,
|
||||
content: String,
|
||||
}
|
||||
let params: CreateParams = decode(Value::Object(arguments), tool_name)?;
|
||||
let destination = relative_target(&path, ¶ms.path, true)?;
|
||||
crate::write::execute_write(target, tracker, params.content, Some(destination), ctx)
|
||||
.await
|
||||
}
|
||||
"Glob" | "Grep" => {
|
||||
let before = selected
|
||||
.session
|
||||
.checkout_observe(path.clone())
|
||||
.await
|
||||
.map_err(crate::file_target::checked_error)?;
|
||||
if before.validator != validator {
|
||||
return Err(stale_search());
|
||||
}
|
||||
let mut result = if tool_name == "Glob" {
|
||||
let params: crate::glob::GlobParams = decode(Value::Object(arguments), tool_name)?;
|
||||
check_pattern(¶ms.pattern)?;
|
||||
let search_path =
|
||||
relative_target(&path, params.path.as_deref().unwrap_or("."), false)?;
|
||||
crate::glob::execute_glob(
|
||||
crate::search_target::SearchTarget::checkout(selected.session.clone()),
|
||||
search_path,
|
||||
params.pattern,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
let params: crate::grep::GrepParams = decode(Value::Object(arguments), tool_name)?;
|
||||
if let Some(glob) = ¶ms.glob {
|
||||
check_pattern(glob)?;
|
||||
}
|
||||
let search_path =
|
||||
relative_target(&path, params.path.as_deref().unwrap_or("."), false)?;
|
||||
crate::grep::execute_grep(
|
||||
crate::search_target::SearchTarget::checkout(selected.session.clone()),
|
||||
search_path,
|
||||
params,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
// Reject rather than emit a link/rendered path outside the binding.
|
||||
// Provider scope is still authoritative for both traversal and content.
|
||||
for result_path in &result.paths {
|
||||
let logical = WorkdirPath::new(result_path.as_str()).map_err(ToolsError::from)?;
|
||||
if !is_beneath(&path, &logical) {
|
||||
return Err(crate::file_target::readonly_result_error(
|
||||
"search paths escaped bound directory",
|
||||
));
|
||||
}
|
||||
}
|
||||
let after = selected
|
||||
.session
|
||||
.checkout_observe(path)
|
||||
.await
|
||||
.map_err(crate::file_target::checked_error)?;
|
||||
if after.validator != before.validator {
|
||||
return Err(stale_search());
|
||||
}
|
||||
result.validator = Some(after.validator);
|
||||
Ok(result)
|
||||
}
|
||||
_ => unreachable!("Operation names checked above"),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode<T: DeserializeOwned>(arguments: Value, name: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_value(arguments)
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid {name} input: {error}")))
|
||||
}
|
||||
|
||||
fn stale_search() -> ToolError {
|
||||
stale_checkout("checkout directory changed; resolve it again before searching")
|
||||
}
|
||||
|
||||
pub(crate) fn stale_checkout(message: impl Into<String>) -> ToolError {
|
||||
ToolError::StructuredConflict {
|
||||
code: "checkout_stale".into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_beneath(base: &WorkdirPath, path: &WorkdirPath) -> bool {
|
||||
base == &WorkdirPath::root()
|
||||
|| std::path::Path::new(path.as_str()).starts_with(std::path::Path::new(base.as_str()))
|
||||
}
|
||||
|
||||
fn relative_target(
|
||||
base: &WorkdirPath,
|
||||
relative: &str,
|
||||
strict: bool,
|
||||
) -> Result<WorkdirPath, ToolError> {
|
||||
if relative.is_empty()
|
||||
|| relative.starts_with('/')
|
||||
|| relative.contains('\\')
|
||||
|| relative.split('/').any(|part| part == "..")
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"path must be relative beneath the bound checkout directory, without parent traversal"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let relative = WorkdirPath::new(relative).map_err(ToolsError::from)?;
|
||||
let joined = if relative == WorkdirPath::root() {
|
||||
base.clone()
|
||||
} else if base == &WorkdirPath::root() {
|
||||
relative
|
||||
} else {
|
||||
WorkdirPath::new(&format!("{}/{}", base.as_str(), relative.as_str()))
|
||||
.map_err(ToolsError::from)?
|
||||
};
|
||||
if !is_beneath(base, &joined) || (strict && joined == *base) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"destination must be strictly beneath the bound checkout directory".into(),
|
||||
));
|
||||
}
|
||||
Ok(joined)
|
||||
}
|
||||
|
||||
fn check_pattern(pattern: &str) -> Result<(), ToolError> {
|
||||
// Patterns filter descendants, not an alternate filesystem starting point.
|
||||
if pattern.starts_with('/') || pattern.split('/').any(|part| part == "..") {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"search glob must stay beneath the bound checkout directory".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "checkout_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,562 @@
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use workdir::{LocalWorkdirSession, WorkdirAttachmentAlias};
|
||||
|
||||
struct Fixture {
|
||||
dir: TempDir,
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
tracker: Tracker,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
let fixture = Self {
|
||||
dir,
|
||||
router,
|
||||
tracker: Tracker::new(),
|
||||
};
|
||||
fixture.attach("main");
|
||||
fixture
|
||||
}
|
||||
|
||||
fn attach(&self, alias: &str) {
|
||||
self.router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new(alias).unwrap(),
|
||||
Arc::new(LocalWorkdirSession::new(
|
||||
Scope::writable(self.dir.path()).unwrap(),
|
||||
self.dir.path().to_path_buf(),
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn observation(&self, alias: &str, path: &str) -> (u64, Vec<u8>) {
|
||||
let selected = self.router.resolve(Some(alias)).unwrap();
|
||||
let observation = selected
|
||||
.session
|
||||
.checkout_observe(WorkdirPath::new(path).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
(selected.generation, observation.validator)
|
||||
}
|
||||
|
||||
async fn native(
|
||||
&self,
|
||||
alias: &str,
|
||||
path: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<CheckoutToolOutput, ToolError> {
|
||||
let (generation, validator) = self.observation(alias, path).await;
|
||||
execute_checkout_tool(
|
||||
self.router.clone(),
|
||||
self.tracker.clone(),
|
||||
alias,
|
||||
generation,
|
||||
WorkdirPath::new(path).unwrap(),
|
||||
validator,
|
||||
name,
|
||||
arguments,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn normal(&self, name: &str, arguments: Value) -> Result<ToolOutput, ToolError> {
|
||||
let definitions = crate::routed_builtin_tools(
|
||||
self.router.clone(),
|
||||
self.tracker.clone(),
|
||||
Default::default(),
|
||||
);
|
||||
for definition in definitions {
|
||||
let (meta, tool) = definition();
|
||||
if meta.name == name {
|
||||
return tool
|
||||
.execute(&arguments.to_string(), Default::default())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
panic!("missing Tool {name}")
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_checkout_stale(error: ToolError) {
|
||||
assert_checkout_code(error, "checkout_stale");
|
||||
}
|
||||
|
||||
fn assert_checkout_code(error: ToolError, expected: &str) {
|
||||
match error {
|
||||
ToolError::StructuredConflict { code, .. } => assert_eq!(code, expected),
|
||||
other => panic!("expected {expected} structured conflict, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_known_refusals_have_typed_codes_without_changing_normal_tools() {
|
||||
use workdir::{WorkdirError, WorkdirSessionCapability};
|
||||
let cases = [
|
||||
(WorkdirError::Denied("denied".into()), "checkout_denied"),
|
||||
(
|
||||
WorkdirError::OutOfScope("outside".into()),
|
||||
"checkout_denied",
|
||||
),
|
||||
(
|
||||
WorkdirError::ReadOnly("read only".into()),
|
||||
"checkout_denied",
|
||||
),
|
||||
(
|
||||
WorkdirError::Unsupported(WorkdirSessionCapability::Write),
|
||||
"checkout_denied",
|
||||
),
|
||||
(
|
||||
WorkdirError::InvalidArgument("bad argument".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
(
|
||||
WorkdirError::InvalidPath("bad path".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
(
|
||||
WorkdirError::Unavailable("budget exhausted".into()),
|
||||
"checkout_unavailable",
|
||||
),
|
||||
(WorkdirError::NotFound("missing".into()), "checkout_stale"),
|
||||
(WorkdirError::SessionClosed, "checkout_unavailable"),
|
||||
(
|
||||
WorkdirError::UnsupportedOperation("unsupported".into()),
|
||||
"checkout_denied",
|
||||
),
|
||||
(
|
||||
WorkdirError::UnknownCommand("missing command".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
(
|
||||
WorkdirError::RelativePath("relative".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
(
|
||||
WorkdirError::IsDirectory("directory".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
(
|
||||
WorkdirError::InvalidGlob("bad glob".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
(
|
||||
WorkdirError::InvalidRegex("bad regex".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
];
|
||||
for (error, code) in cases {
|
||||
assert_checkout_code(crate::file_target::checked_error(error), code);
|
||||
}
|
||||
assert!(matches!(
|
||||
ToolError::from(ToolsError::from(WorkdirError::Unavailable(
|
||||
"unavailable".into()
|
||||
))),
|
||||
ToolError::ExecutionFailed(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
ToolError::from(ToolsError::from(WorkdirError::Unsupported(
|
||||
WorkdirSessionCapability::Write
|
||||
))),
|
||||
ToolError::InvalidArgument(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_conflict_classification_preserves_normal_tools_and_unknown_outcomes() {
|
||||
assert_checkout_stale(crate::file_target::checked_error(
|
||||
workdir::WorkdirError::Conflict("a.txt".into()),
|
||||
));
|
||||
assert_checkout_stale(stale_search());
|
||||
assert!(matches!(
|
||||
ToolError::from(ToolsError::from(workdir::WorkdirError::Conflict(
|
||||
"a.txt".into()
|
||||
))),
|
||||
ToolError::ExecutionFailed(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
crate::file_target::checked_error(workdir::WorkdirError::OutcomeUnknown(
|
||||
"may have committed".into()
|
||||
)),
|
||||
ToolError::ExecutionFailed(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_ambiguous_transport_and_operation_failures_remain_unknown() {
|
||||
use workdir::WorkdirError;
|
||||
// Even refusal-looking text in an ambiguous transport error is not proof
|
||||
// that the provider rejected the request before any effects.
|
||||
for error in [
|
||||
WorkdirError::Transport("denied (ambiguous response)".into()),
|
||||
WorkdirError::OperationFailed,
|
||||
] {
|
||||
assert!(matches!(
|
||||
crate::file_target::checked_error(error),
|
||||
ToolError::ExecutionFailed(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_mismatched_readonly_results_are_unavailable_not_stale_or_unknown() {
|
||||
use workdir::{CheckoutSearchResult, GlobResult, GrepResult};
|
||||
let glob = CheckoutSearchResult::Glob(GlobResult {
|
||||
paths: Vec::new(),
|
||||
truncated: false,
|
||||
});
|
||||
let grep = CheckoutSearchResult::Grep(GrepResult {
|
||||
output: String::new(),
|
||||
match_count: 0,
|
||||
matched_files: 0,
|
||||
truncated: false,
|
||||
paths: Vec::new(),
|
||||
});
|
||||
assert_checkout_code(
|
||||
crate::search_target::grep_result(glob).err().unwrap(),
|
||||
"checkout_unavailable",
|
||||
);
|
||||
assert_checkout_code(
|
||||
crate::search_target::glob_result(grep).err().unwrap(),
|
||||
"checkout_unavailable",
|
||||
);
|
||||
assert_checkout_code(
|
||||
crate::file_target::readonly_result_error("Read"),
|
||||
"checkout_unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_relative_path_boundaries() {
|
||||
let base = WorkdirPath::new("src").unwrap();
|
||||
assert_eq!(
|
||||
relative_target(&base, "nested/a.txt", true)
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"src/nested/a.txt"
|
||||
);
|
||||
assert_eq!(relative_target(&base, ".", false).unwrap(), base);
|
||||
assert_eq!(
|
||||
relative_target(&WorkdirPath::root(), "a.txt", true)
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"a.txt"
|
||||
);
|
||||
for bad in [
|
||||
"",
|
||||
"..",
|
||||
"../outside",
|
||||
"nested/../../outside",
|
||||
"/absolute",
|
||||
"a\\..\\outside",
|
||||
] {
|
||||
assert!(
|
||||
relative_target(&base, bad, false).is_err(),
|
||||
"accepted {bad}"
|
||||
);
|
||||
}
|
||||
assert!(relative_target(&base, ".", true).is_err());
|
||||
assert!(!is_beneath(
|
||||
&base,
|
||||
&WorkdirPath::new("src-other/a.txt").unwrap()
|
||||
));
|
||||
assert!(check_pattern("../*").is_err());
|
||||
assert!(check_pattern("/tmp/*").is_err());
|
||||
assert!(check_pattern("**/*.rs").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_read_and_normal_edit_share_hash_and_line_count() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "alpha\nbeta\ngamma\n").unwrap();
|
||||
let read = fixture
|
||||
.native("main", "a.txt", "Read", json!({"offset": 1, "limit": 1}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(read.output.summary.contains("[2..2] of 3"));
|
||||
assert_eq!(read.output.content.as_deref(), Some(" 2\tbeta\n"));
|
||||
assert!(read.validator.is_some());
|
||||
let selected = fixture.router.resolve(Some("main")).unwrap();
|
||||
let tracker = fixture
|
||||
.tracker
|
||||
.scoped_attachment(&selected.alias, selected.generation);
|
||||
assert_eq!(
|
||||
tracker.observed_workdir_line_count(&WorkdirPath::new("a.txt").unwrap()),
|
||||
Some(3)
|
||||
);
|
||||
fixture.normal("Edit", json!({"target_workdir": "main", "file_path": "a.txt", "old_string": "beta", "new_string": "changed"})).await.unwrap();
|
||||
let write = fixture
|
||||
.native("main", "a.txt", "Write", json!({"content": "new\n"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(write.output.summary.contains("Overwrote"));
|
||||
assert_eq!(fixture.tracker.change_stat().deleted, 4);
|
||||
let reread = fixture
|
||||
.native("main", "a.txt", "Read", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(reread.output.content.as_deref(), Some(" 1\tnew\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_normal_read_then_native_edit_uniqueness_and_replace_all() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "x x x\n").unwrap();
|
||||
let before_read = fixture
|
||||
.native(
|
||||
"main",
|
||||
"a.txt",
|
||||
"Edit",
|
||||
json!({"old_string": "x", "new_string": "y"}),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(before_read.to_string().contains("has not been read"));
|
||||
fixture
|
||||
.normal(
|
||||
"Read",
|
||||
json!({"target_workdir": "main", "file_path": "a.txt"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let not_unique = fixture
|
||||
.native(
|
||||
"main",
|
||||
"a.txt",
|
||||
"Edit",
|
||||
json!({"old_string": "x", "new_string": "y"}),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_checkout_code(not_unique, "checkout_invalid");
|
||||
assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default());
|
||||
let edit = fixture
|
||||
.native(
|
||||
"main",
|
||||
"a.txt",
|
||||
"Edit",
|
||||
json!({"old_string": "x", "new_string": "y", "replace_all": true}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(edit.output.summary.contains("3 replacements"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fixture.dir.path().join("a.txt")).unwrap(),
|
||||
"y y y\n"
|
||||
);
|
||||
fixture.normal("Write", json!({"target_workdir": "main", "file_path": "a.txt", "content": "normal after native"})).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_alias_and_generation_fences() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.attach("other");
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "same\n").unwrap();
|
||||
fixture
|
||||
.native("main", "a.txt", "Read", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
let wrong_alias = fixture
|
||||
.native("other", "a.txt", "Write", json!({"content": "not allowed"}))
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(wrong_alias.to_string().contains("has not been read"));
|
||||
let (old_generation, old_validator) = fixture.observation("main", "a.txt").await;
|
||||
fixture
|
||||
.router
|
||||
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
fixture.attach("main");
|
||||
let error = execute_checkout_tool(
|
||||
fixture.router.clone(),
|
||||
fixture.tracker.clone(),
|
||||
"main",
|
||||
old_generation,
|
||||
WorkdirPath::new("a.txt").unwrap(),
|
||||
old_validator,
|
||||
"Read",
|
||||
json!({}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.to_string().contains("generation changed"));
|
||||
assert_checkout_stale(error);
|
||||
let fresh_generation_no_read = fixture
|
||||
.native("main", "a.txt", "Write", json!({"content": "not allowed"}))
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
fresh_generation_no_read
|
||||
.to_string()
|
||||
.contains("has not been read")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_stale_validator_and_hash_are_independent() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "old\n").unwrap();
|
||||
fixture
|
||||
.native("main", "a.txt", "Read", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
let (generation, validator) = fixture.observation("main", "a.txt").await;
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "externally changed\n").unwrap();
|
||||
let stale = execute_checkout_tool(
|
||||
fixture.router.clone(),
|
||||
fixture.tracker.clone(),
|
||||
"main",
|
||||
generation,
|
||||
WorkdirPath::new("a.txt").unwrap(),
|
||||
validator,
|
||||
"Write",
|
||||
json!({"content": "bad"}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_checkout_stale(stale);
|
||||
let hash = fixture
|
||||
.native("main", "a.txt", "Write", json!({"content": "bad"}))
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_checkout_stale(hash);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fixture.dir.path().join("a.txt")).unwrap(),
|
||||
"externally changed\n"
|
||||
);
|
||||
assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_create_new_missing_parents_never_overwrites() {
|
||||
let fixture = Fixture::new();
|
||||
let created = fixture
|
||||
.native(
|
||||
"main",
|
||||
".",
|
||||
"Create",
|
||||
json!({"path": "nested/deep/a.txt", "content": "new\n"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(created.output.summary.contains("Created"));
|
||||
assert_eq!(
|
||||
created.paths,
|
||||
vec![WorkdirPath::new("nested/deep/a.txt").unwrap()]
|
||||
);
|
||||
let duplicate = fixture
|
||||
.native(
|
||||
"main",
|
||||
".",
|
||||
"Create",
|
||||
json!({"path": "nested/deep/a.txt", "content": "overwrite"}),
|
||||
)
|
||||
.await;
|
||||
assert!(duplicate.is_err());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fixture.dir.path().join("nested/deep/a.txt")).unwrap(),
|
||||
"new\n"
|
||||
);
|
||||
// Successful creation updates the same tracker as normal Tool Write.
|
||||
fixture.normal("Write", json!({"target_workdir": "main", "file_path": "nested/deep/a.txt", "content": "changed"})).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_rejects_argument_overrides_and_escapes() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "safe").unwrap();
|
||||
for (name, arguments) in [
|
||||
("Read", json!({"file_path": "other"})),
|
||||
("Read", json!({"target_workdir": "other"})),
|
||||
("Write", json!({"content": "bad", "validator": []})),
|
||||
("Create", json!({"path": "../outside", "content": "bad"})),
|
||||
("Glob", json!({"pattern": "*", "path": "/absolute"})),
|
||||
("Grep", json!({"pattern": "safe", "path": "../outside"})),
|
||||
("Other", json!({})),
|
||||
] {
|
||||
let path = if ["Create", "Glob", "Grep"].contains(&name) {
|
||||
"."
|
||||
} else {
|
||||
"a.txt"
|
||||
};
|
||||
let error = fixture
|
||||
.native("main", path, name, arguments)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(error, ToolError::InvalidArgument(_)),
|
||||
"{name}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_typed_paths_and_normal_rendering() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::create_dir_all(fixture.dir.path().join("src/nested")).unwrap();
|
||||
std::fs::write(
|
||||
fixture.dir.path().join("src/nested/a.txt"),
|
||||
"context\nhit\nafter\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(fixture.dir.path().join("outside.txt"), "hit\n").unwrap();
|
||||
let glob = fixture
|
||||
.native(
|
||||
"main",
|
||||
"src",
|
||||
"Glob",
|
||||
json!({"pattern": "**/*.txt", "path": "nested"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
glob.paths,
|
||||
vec![WorkdirPath::new("src/nested/a.txt").unwrap()]
|
||||
);
|
||||
let normal = fixture
|
||||
.normal(
|
||||
"Glob",
|
||||
json!({"target_workdir": "main", "pattern": "**/*.txt", "path": "src/nested"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(glob.output.summary, normal.summary);
|
||||
assert_eq!(glob.output.content, normal.content);
|
||||
for mode in ["files_with_matches", "content", "count"] {
|
||||
let grep = fixture
|
||||
.native(
|
||||
"main",
|
||||
"src",
|
||||
"Grep",
|
||||
json!({"pattern": "hit", "output_mode": mode, "-C": 1}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
grep.paths,
|
||||
vec![WorkdirPath::new("src/nested/a.txt").unwrap()]
|
||||
);
|
||||
let normal = fixture.normal("Grep", json!({"target_workdir": "main", "path": "src", "pattern": "hit", "output_mode": mode, "-C": 1})).await.unwrap();
|
||||
assert_eq!(grep.output.summary, normal.summary);
|
||||
assert_eq!(grep.output.content, normal.content);
|
||||
assert!(!grep.output.content.unwrap().contains("outside.txt"));
|
||||
}
|
||||
}
|
||||
+65
-47
@@ -9,7 +9,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::tracker::Tracker;
|
||||
use workdir::{EditRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
use workdir::{WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
|
||||
const DESCRIPTION: &str = "Replace a substring in an existing file in the selected Workdir attachment. By default \
|
||||
`old_string` must be unique in the file; set `replace_all: true` to replace \
|
||||
@@ -56,56 +56,74 @@ impl Tool for EditTool {
|
||||
.tracker
|
||||
.scoped_attachment(&selected.alias, selected.generation);
|
||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
tracing::debug!(path = %path, replace_all = params.replace_all, "Edit");
|
||||
Ok(execute_edit(
|
||||
crate::file_target::FileTarget {
|
||||
session: selected.session,
|
||||
path,
|
||||
validator: None,
|
||||
},
|
||||
tracker,
|
||||
params,
|
||||
ctx,
|
||||
)
|
||||
.await?
|
||||
.output)
|
||||
}
|
||||
}
|
||||
|
||||
if params.old_string.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"old_string must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if params.old_string == params.new_string {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"old_string and new_string are identical".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mutation_key = PathBuf::from(path.as_str());
|
||||
let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await;
|
||||
let expected_hash = tracker.expected_workdir_hash(&path)?;
|
||||
let result = selected
|
||||
.session
|
||||
.edit(EditRequest {
|
||||
path: path.clone(),
|
||||
old_string: params.old_string.clone(),
|
||||
new_string: params.new_string.clone(),
|
||||
replace_all: params.replace_all,
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
let replacements = result.replacements;
|
||||
tracker.record_workdir_edit(
|
||||
&path,
|
||||
result.content_hash,
|
||||
replacements,
|
||||
params.new_string.lines().count(),
|
||||
params.old_string.lines().count(),
|
||||
);
|
||||
|
||||
let summary = format!(
|
||||
"Edited {} ({} replacement{})",
|
||||
path,
|
||||
replacements,
|
||||
if replacements == 1 { "" } else { "s" }
|
||||
);
|
||||
let preview = make_preview(¶ms.new_string, ¶ms.new_string);
|
||||
|
||||
Ok(ToolOutput {
|
||||
pub(crate) async fn execute_edit(
|
||||
target: crate::file_target::FileTarget,
|
||||
tracker: Tracker,
|
||||
params: EditParams,
|
||||
ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
let path = &target.path;
|
||||
tracing::debug!(path = %path, replace_all = params.replace_all, "Edit");
|
||||
if params.old_string.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"old_string must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if params.old_string == params.new_string {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"old_string and new_string are identical".into(),
|
||||
));
|
||||
}
|
||||
let mutation_key = PathBuf::from(path.as_str());
|
||||
let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await;
|
||||
let expected_hash = tracker.expected_workdir_hash(path)?;
|
||||
let (result, validator) = target
|
||||
.edit(
|
||||
params.old_string.clone(),
|
||||
params.new_string.clone(),
|
||||
params.replace_all,
|
||||
expected_hash,
|
||||
)
|
||||
.await?;
|
||||
let replacements = result.replacements;
|
||||
tracker.record_workdir_edit(
|
||||
path,
|
||||
result.content_hash,
|
||||
replacements,
|
||||
params.new_string.lines().count(),
|
||||
params.old_string.lines().count(),
|
||||
);
|
||||
let summary = format!(
|
||||
"Edited {} ({} replacement{})",
|
||||
path,
|
||||
replacements,
|
||||
if replacements == 1 { "" } else { "s" }
|
||||
);
|
||||
let preview = make_preview(¶ms.new_string, ¶ms.new_string);
|
||||
Ok(crate::checkout::CheckoutToolOutput {
|
||||
output: ToolOutput {
|
||||
summary,
|
||||
content: Some(preview),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
},
|
||||
paths: Vec::new(),
|
||||
validator,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a small line-numbered snippet centered on the first occurrence of
|
||||
|
||||
@@ -44,7 +44,8 @@ impl From<ToolsError> for ToolError {
|
||||
| workdir::WorkdirError::Unavailable(_)
|
||||
| workdir::WorkdirError::OperationFailed
|
||||
| workdir::WorkdirError::Transport(_)
|
||||
| workdir::WorkdirError::Conflict(_),
|
||||
| workdir::WorkdirError::Conflict(_)
|
||||
| workdir::WorkdirError::OutcomeUnknown(_),
|
||||
) => ToolError::ExecutionFailed(err.to_string()),
|
||||
ToolsError::FileSystem(_)
|
||||
| ToolsError::WorkdirSession(_)
|
||||
@@ -62,6 +63,22 @@ mod tests {
|
||||
use super::*;
|
||||
use workdir::http::{WorkdirTransportError, WorkdirTransportErrorCode};
|
||||
|
||||
#[test]
|
||||
fn checkout_outcome_unknown_remains_execution_failure() {
|
||||
let error = ToolError::from(ToolsError::WorkdirSession(
|
||||
workdir::WorkdirError::OutcomeUnknown(
|
||||
"mutation may have committed; do not retry automatically".into(),
|
||||
),
|
||||
));
|
||||
match error {
|
||||
ToolError::ExecutionFailed(message) => {
|
||||
assert!(message.contains("mutation may have committed"));
|
||||
assert!(message.contains("do not retry automatically"));
|
||||
}
|
||||
other => panic!("outcome unknown must not become invalid input: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_workdir_content_conflict_is_retryable_execution_failure() {
|
||||
let error = ToolError::from(ToolsError::WorkdirSession(
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Shared provider dispatch for Tool and checkout file processing.
|
||||
use agen::tool::ToolError;
|
||||
use workdir::{
|
||||
CheckoutOperation, CheckoutOutput, CheckoutRequest, ContentHash, EditRequest, EditResult,
|
||||
ReadRequest, ReadResult, WorkdirPath, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
use crate::ToolsError;
|
||||
|
||||
pub(crate) struct FileTarget {
|
||||
pub session: WorkdirSessionHandle,
|
||||
pub path: WorkdirPath,
|
||||
pub validator: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl FileTarget {
|
||||
async fn checked(
|
||||
&self,
|
||||
operation: CheckoutOperation,
|
||||
) -> Result<(CheckoutOutput, Option<Vec<u8>>), ToolError> {
|
||||
let result = self
|
||||
.session
|
||||
.checkout_execute(CheckoutRequest {
|
||||
target: self.path.clone(),
|
||||
validator: self
|
||||
.validator
|
||||
.clone()
|
||||
.expect("checked dispatch requires validator"),
|
||||
operation,
|
||||
})
|
||||
.await
|
||||
.map_err(checked_error)?;
|
||||
Ok((result.output, Some(result.observation.validator)))
|
||||
}
|
||||
|
||||
pub async fn read(
|
||||
&self,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
max_bytes: usize,
|
||||
) -> Result<(ReadResult, Option<Vec<u8>>), ToolError> {
|
||||
if self.validator.is_some() {
|
||||
let (output, validator) = self
|
||||
.checked(CheckoutOperation::Read {
|
||||
offset,
|
||||
limit,
|
||||
max_bytes,
|
||||
})
|
||||
.await?;
|
||||
match output {
|
||||
CheckoutOutput::Read(result) => Ok((result, validator)),
|
||||
_ => Err(readonly_result_error("Read")),
|
||||
}
|
||||
} else {
|
||||
let result = self
|
||||
.session
|
||||
.read(ReadRequest {
|
||||
path: self.path.clone(),
|
||||
offset,
|
||||
limit,
|
||||
max_bytes,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
Ok((result, None))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
&self,
|
||||
old_string: String,
|
||||
new_string: String,
|
||||
replace_all: bool,
|
||||
expected_hash: ContentHash,
|
||||
) -> Result<(EditResult, Option<Vec<u8>>), ToolError> {
|
||||
if self.validator.is_some() {
|
||||
let (output, validator) = self
|
||||
.checked(CheckoutOperation::Edit {
|
||||
old_string,
|
||||
new_string,
|
||||
replace_all,
|
||||
expected_hash,
|
||||
})
|
||||
.await?;
|
||||
match output {
|
||||
CheckoutOutput::Edit(result) => Ok((result, validator)),
|
||||
_ => Err(unknown_result("Edit")),
|
||||
}
|
||||
} else {
|
||||
let result = self
|
||||
.session
|
||||
.edit(EditRequest {
|
||||
path: self.path.clone(),
|
||||
old_string,
|
||||
new_string,
|
||||
replace_all,
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
Ok((result, None))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write(
|
||||
&self,
|
||||
content: Vec<u8>,
|
||||
expected_hash: Option<ContentHash>,
|
||||
create_path: Option<WorkdirPath>,
|
||||
) -> Result<(WriteResult, Option<Vec<u8>>), ToolError> {
|
||||
if self.validator.is_some() {
|
||||
let operation = match create_path {
|
||||
Some(path) => CheckoutOperation::Create { path, content },
|
||||
None => CheckoutOperation::Write {
|
||||
content,
|
||||
expected_hash: expected_hash
|
||||
.expect("existing checkout Write requires prior read"),
|
||||
},
|
||||
};
|
||||
let (output, validator) = self.checked(operation).await?;
|
||||
match output {
|
||||
CheckoutOutput::Write(result) => Ok((result, validator)),
|
||||
_ => Err(unknown_result("Write/Create")),
|
||||
}
|
||||
} else {
|
||||
let result = self
|
||||
.session
|
||||
.write(WriteRequest {
|
||||
path: self.path.clone(),
|
||||
content,
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
Ok((result, None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn checked_error(error: workdir::WorkdirError) -> ToolError {
|
||||
use workdir::WorkdirError;
|
||||
let code = match &error {
|
||||
WorkdirError::Conflict(_) | WorkdirError::NotFound(_) => "checkout_stale",
|
||||
WorkdirError::Denied { .. }
|
||||
| WorkdirError::OutOfScope { .. }
|
||||
| WorkdirError::ReadOnly { .. }
|
||||
| WorkdirError::Unsupported { .. }
|
||||
| WorkdirError::UnsupportedOperation { .. }
|
||||
| WorkdirError::SymlinkOutOfScope { .. }
|
||||
| WorkdirError::SymlinkDirectoryNotTraversed { .. } => "checkout_denied",
|
||||
WorkdirError::InvalidArgument { .. }
|
||||
| WorkdirError::InvalidPath { .. }
|
||||
| WorkdirError::UnknownCommand { .. }
|
||||
| WorkdirError::RelativePath { .. }
|
||||
| WorkdirError::BrokenSymlink { .. }
|
||||
| WorkdirError::SymlinkTargetIsDirectory { .. }
|
||||
| WorkdirError::IsDirectory { .. }
|
||||
| WorkdirError::InvalidGlob { .. }
|
||||
| WorkdirError::InvalidRegex { .. } => "checkout_invalid",
|
||||
WorkdirError::Unavailable { .. } | WorkdirError::SessionClosed => "checkout_unavailable",
|
||||
// IO/transport failures may arrive after effects have begun. Do not infer
|
||||
// no-effects from their text or strip explicit OutcomeUnknown evidence.
|
||||
WorkdirError::Io { .. }
|
||||
| WorkdirError::Transport { .. }
|
||||
| WorkdirError::OutcomeUnknown { .. }
|
||||
| WorkdirError::OperationFailed => {
|
||||
return ToolsError::from(error).into();
|
||||
}
|
||||
};
|
||||
ToolError::StructuredConflict {
|
||||
code: code.into(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn readonly_result_error(operation: &str) -> ToolError {
|
||||
checked_error(workdir::WorkdirError::Unavailable(format!(
|
||||
"provider returned an invalid readonly checkout {operation} result"
|
||||
)))
|
||||
}
|
||||
|
||||
fn unknown_result(operation: &str) -> ToolError {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"Outcome unknown: provider returned wrong checkout {operation} result after mutation; do not automatically retry"
|
||||
))
|
||||
}
|
||||
+52
-37
@@ -11,15 +11,15 @@ use crate::ToolsError;
|
||||
const RESULT_LIMIT: usize = 1000;
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GlobParams {
|
||||
pub(crate) struct GlobParams {
|
||||
/// Worker-local alias of the Workdir attachment to use.
|
||||
#[serde(default)]
|
||||
target_workdir: Option<String>,
|
||||
pub target_workdir: Option<String>,
|
||||
/// Glob pattern, for example `**/*.rs` or `src/**/test_*.py`.
|
||||
pattern: String,
|
||||
pub pattern: String,
|
||||
/// Logical Workdir-relative directory. Defaults to the Workdir root.
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
struct GlobTool {
|
||||
@@ -44,42 +44,57 @@ impl Tool for GlobTool {
|
||||
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?,
|
||||
None => WorkdirPath::root(),
|
||||
};
|
||||
let pattern = params.pattern;
|
||||
tracing::debug!(%pattern, %path, "Glob");
|
||||
let result = selected
|
||||
.session
|
||||
.glob(GlobRequest {
|
||||
pattern: pattern.clone(),
|
||||
path,
|
||||
limit: RESULT_LIMIT,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
let mut body = result
|
||||
.paths
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !body.is_empty() {
|
||||
body.push('\n');
|
||||
}
|
||||
let summary = if result.paths.is_empty() {
|
||||
format!("No files found matching {pattern}")
|
||||
} else if result.truncated {
|
||||
format!(
|
||||
"Found {}+ files matching {pattern} (truncated to {RESULT_LIMIT})",
|
||||
result.paths.len()
|
||||
)
|
||||
} else {
|
||||
format!("Found {} file(s) matching {pattern}", result.paths.len())
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
Ok(execute_glob(
|
||||
crate::search_target::SearchTarget::tool(selected.session),
|
||||
path,
|
||||
params.pattern,
|
||||
)
|
||||
.await?
|
||||
.output)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_glob(
|
||||
target: crate::search_target::SearchTarget,
|
||||
path: WorkdirPath,
|
||||
pattern: String,
|
||||
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
tracing::debug!(%pattern, %path, "Glob");
|
||||
let result = target
|
||||
.glob(GlobRequest {
|
||||
pattern: pattern.clone(),
|
||||
path,
|
||||
limit: RESULT_LIMIT,
|
||||
})
|
||||
.await?;
|
||||
let mut body = result
|
||||
.paths
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !body.is_empty() {
|
||||
body.push('\n');
|
||||
}
|
||||
let summary = if result.paths.is_empty() {
|
||||
format!("No files found matching {pattern}")
|
||||
} else if result.truncated {
|
||||
format!(
|
||||
"Found {}+ files matching {pattern} (truncated to {RESULT_LIMIT})",
|
||||
result.paths.len()
|
||||
)
|
||||
} else {
|
||||
format!("Found {} file(s) matching {pattern}", result.paths.len())
|
||||
};
|
||||
Ok(crate::checkout::CheckoutToolOutput {
|
||||
output: ToolOutput {
|
||||
summary,
|
||||
content: (!body.is_empty()).then_some(body),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
},
|
||||
paths: result.paths,
|
||||
validator: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn glob_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
||||
|
||||
+75
-59
@@ -22,16 +22,16 @@ enum OutputMode {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GrepParams {
|
||||
pub(crate) struct GrepParams {
|
||||
/// Worker-local alias of the Workdir attachment to use.
|
||||
#[serde(default)]
|
||||
target_workdir: Option<String>,
|
||||
pattern: String,
|
||||
/// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root.
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
pub path: Option<String>,
|
||||
#[serde(default)]
|
||||
glob: Option<String>,
|
||||
pub glob: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
file_type: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -70,69 +70,85 @@ impl Tool for GrepTool {
|
||||
params.target_workdir.as_deref(),
|
||||
workdir::WorkdirSessionCapability::Grep,
|
||||
)?;
|
||||
let path = match params.path {
|
||||
Some(path) => WorkdirPath::new_scoped(&path).map_err(ToolsError::from)?,
|
||||
let path = match params.path.as_deref() {
|
||||
Some(path) => WorkdirPath::new_scoped(path).map_err(ToolsError::from)?,
|
||||
None => WorkdirPath::root(),
|
||||
};
|
||||
let mode = match params.output_mode.unwrap_or_default() {
|
||||
OutputMode::FilesWithMatches => GrepOutputMode::FilesWithMatches,
|
||||
OutputMode::Content => GrepOutputMode::Content,
|
||||
OutputMode::Count => GrepOutputMode::Count,
|
||||
};
|
||||
let (before_context, after_context) = params
|
||||
.context
|
||||
.map(|context| (context, context))
|
||||
.unwrap_or((params.before.unwrap_or(0), params.after.unwrap_or(0)));
|
||||
let head_limit = params.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||
let result = selected
|
||||
.session
|
||||
.grep(GrepRequest {
|
||||
pattern: params.pattern,
|
||||
path,
|
||||
glob: params.glob,
|
||||
file_type: params.file_type,
|
||||
case_insensitive: params.case_insensitive,
|
||||
before_context,
|
||||
after_context,
|
||||
multiline: params.multiline,
|
||||
output_mode: mode,
|
||||
limit: head_limit,
|
||||
offset: params.offset.unwrap_or(0),
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
Ok(execute_grep(
|
||||
crate::search_target::SearchTarget::tool(selected.session),
|
||||
path,
|
||||
params,
|
||||
)
|
||||
.await?
|
||||
.output)
|
||||
}
|
||||
}
|
||||
|
||||
let summary = if result.match_count == 0 {
|
||||
match mode {
|
||||
GrepOutputMode::Content => "No matches".to_owned(),
|
||||
_ => "No files matched".to_owned(),
|
||||
pub(crate) async fn execute_grep(
|
||||
target: crate::search_target::SearchTarget,
|
||||
path: WorkdirPath,
|
||||
params: GrepParams,
|
||||
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
let mode = match params.output_mode.unwrap_or_default() {
|
||||
OutputMode::FilesWithMatches => GrepOutputMode::FilesWithMatches,
|
||||
OutputMode::Content => GrepOutputMode::Content,
|
||||
OutputMode::Count => GrepOutputMode::Count,
|
||||
};
|
||||
let (before_context, after_context) = params
|
||||
.context
|
||||
.map(|context| (context, context))
|
||||
.unwrap_or((params.before.unwrap_or(0), params.after.unwrap_or(0)));
|
||||
let head_limit = params.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||
let result = target
|
||||
.grep(GrepRequest {
|
||||
pattern: params.pattern,
|
||||
path,
|
||||
glob: params.glob,
|
||||
file_type: params.file_type,
|
||||
case_insensitive: params.case_insensitive,
|
||||
before_context,
|
||||
after_context,
|
||||
multiline: params.multiline,
|
||||
output_mode: mode,
|
||||
limit: head_limit,
|
||||
offset: params.offset.unwrap_or(0),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let summary = if result.match_count == 0 {
|
||||
match mode {
|
||||
GrepOutputMode::Content => "No matches".to_owned(),
|
||||
_ => "No files matched".to_owned(),
|
||||
}
|
||||
} else {
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
format!("Found matches in {} file(s)", result.matched_files)
|
||||
}
|
||||
} else {
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
format!("Found matches in {} file(s)", result.matched_files)
|
||||
}
|
||||
GrepOutputMode::Count => format!(
|
||||
"Found matches in {} file(s), {} total line(s)",
|
||||
result.matched_files, result.match_count
|
||||
),
|
||||
GrepOutputMode::Content => format!(
|
||||
"{} matching line(s) in {} file(s)",
|
||||
result.match_count, result.matched_files
|
||||
),
|
||||
}
|
||||
};
|
||||
let summary = if result.truncated {
|
||||
format!("{summary} (truncated at {head_limit})")
|
||||
} else {
|
||||
summary
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
GrepOutputMode::Count => format!(
|
||||
"Found matches in {} file(s), {} total line(s)",
|
||||
result.matched_files, result.match_count
|
||||
),
|
||||
GrepOutputMode::Content => format!(
|
||||
"{} matching line(s) in {} file(s)",
|
||||
result.match_count, result.matched_files
|
||||
),
|
||||
}
|
||||
};
|
||||
let summary = if result.truncated {
|
||||
format!("{summary} (truncated at {head_limit})")
|
||||
} else {
|
||||
summary
|
||||
};
|
||||
Ok(crate::checkout::CheckoutToolOutput {
|
||||
output: ToolOutput {
|
||||
summary,
|
||||
content: (!result.output.is_empty()).then_some(result.output),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
},
|
||||
paths: result.paths,
|
||||
validator: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
||||
|
||||
@@ -9,20 +9,24 @@
|
||||
//! and command capability, while the Runtime process and OS user remain the
|
||||
//! trusted execution boundary.
|
||||
|
||||
pub mod checkout;
|
||||
pub mod error;
|
||||
pub mod tracker;
|
||||
|
||||
mod bash;
|
||||
mod edit;
|
||||
mod file_target;
|
||||
mod glob;
|
||||
mod grep;
|
||||
mod read;
|
||||
mod routing;
|
||||
mod search_target;
|
||||
mod view_image;
|
||||
mod web;
|
||||
mod write;
|
||||
|
||||
pub use bash::bash_tool;
|
||||
pub use checkout::{CheckoutToolOutput, execute_checkout_tool};
|
||||
pub use edit::edit_tool;
|
||||
pub use error::ToolsError;
|
||||
pub use glob::glob_tool;
|
||||
|
||||
+101
-74
@@ -8,7 +8,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::tracker::Tracker;
|
||||
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
use workdir::{WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
|
||||
const DESCRIPTION: &str = "Read a text file from a Workdir attachment selected by its Worker-local alias. \
|
||||
Supports offset/limit for large files. Returns line-numbered output (1-based). \
|
||||
@@ -56,51 +56,59 @@ impl Tool for ReadTool {
|
||||
let tracker = self
|
||||
.tracker
|
||||
.scoped_attachment(&selected.alias, selected.generation);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||
Ok(execute_read(
|
||||
crate::file_target::FileTarget {
|
||||
session: selected.session,
|
||||
path: WorkdirPath::new_scoped(¶ms.file_path).map_err(ToolsError::from)?,
|
||||
validator: None,
|
||||
},
|
||||
tracker,
|
||||
params,
|
||||
)
|
||||
.await?
|
||||
.output)
|
||||
}
|
||||
}
|
||||
|
||||
let path = WorkdirPath::new_scoped(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
tracing::debug!(path = %path, offset, limit, "Read");
|
||||
|
||||
let result = selected
|
||||
.session
|
||||
.read(ReadRequest {
|
||||
path: path.clone(),
|
||||
offset,
|
||||
limit,
|
||||
max_bytes: PROVIDER_BYTE_LIMIT,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
tracker.record_workdir_hash(&path, result.content_hash);
|
||||
|
||||
let text = String::from_utf8_lossy(&result.bytes).into_owned();
|
||||
let rendered = render_provider_read(
|
||||
&text,
|
||||
result.start_line,
|
||||
result.total_lines,
|
||||
result.truncated,
|
||||
);
|
||||
|
||||
let summary = if rendered.truncated {
|
||||
format!(
|
||||
"Read {} line(s) [{}..{}] of {} from {}",
|
||||
rendered.line_count,
|
||||
offset + 1,
|
||||
offset + rendered.line_count,
|
||||
rendered.total_lines,
|
||||
path
|
||||
)
|
||||
} else {
|
||||
format!("Read {} line(s) from {}", rendered.line_count, path)
|
||||
};
|
||||
|
||||
Ok(ToolOutput {
|
||||
pub(crate) async fn execute_read(
|
||||
target: crate::file_target::FileTarget,
|
||||
tracker: Tracker,
|
||||
params: ReadParams,
|
||||
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||
let path = &target.path;
|
||||
tracing::debug!(path = %path, offset, limit, "Read");
|
||||
let (result, validator) = target.read(offset, limit, PROVIDER_BYTE_LIMIT).await?;
|
||||
tracker.record_workdir_observation(path, result.content_hash, result.total_lines);
|
||||
let text = String::from_utf8_lossy(&result.bytes).into_owned();
|
||||
let rendered = render_provider_read(
|
||||
&text,
|
||||
result.start_line,
|
||||
result.total_lines,
|
||||
result.truncated,
|
||||
);
|
||||
let summary = if rendered.truncated {
|
||||
format!(
|
||||
"Read {} line(s) [{}..{}] of {} from {}",
|
||||
rendered.line_count,
|
||||
offset.saturating_add(1),
|
||||
offset.saturating_add(rendered.line_count),
|
||||
rendered.total_lines,
|
||||
path
|
||||
)
|
||||
} else {
|
||||
format!("Read {} line(s) from {}", rendered.line_count, path)
|
||||
};
|
||||
Ok(crate::checkout::CheckoutToolOutput {
|
||||
output: ToolOutput {
|
||||
summary,
|
||||
content: Some(rendered.body),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
},
|
||||
paths: Vec::new(),
|
||||
validator,
|
||||
})
|
||||
}
|
||||
|
||||
struct Rendered {
|
||||
@@ -116,43 +124,36 @@ fn render_provider_read(
|
||||
total_lines: usize,
|
||||
truncated: bool,
|
||||
) -> Rendered {
|
||||
use std::fmt::Write as _;
|
||||
let lines = text.lines().collect::<Vec<_>>();
|
||||
let mut body = String::with_capacity(text.len().saturating_add(lines.len() * 8));
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let _ = writeln!(&mut body, "{:>6}\t{}", start_line + index + 1, line);
|
||||
const MARKER: &str = "\n[truncated at rendered output byte limit]\n";
|
||||
// The numbered presentation is bounded too, not only provider source bytes.
|
||||
// This fits the native wire limit even with JSON's worst-case escaping.
|
||||
let budget = PROVIDER_BYTE_LIMIT - MARKER.len();
|
||||
let mut body = String::with_capacity(text.len().min(PROVIDER_BYTE_LIMIT));
|
||||
let mut line_count = 0;
|
||||
let mut output_truncated = false;
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
let numbered = format!("{:>6}\t{}\n", start_line + index + 1, line);
|
||||
let remaining = budget.saturating_sub(body.len());
|
||||
if remaining > 0 {
|
||||
line_count += 1;
|
||||
}
|
||||
if numbered.len() > remaining {
|
||||
let mut end = remaining;
|
||||
while !numbered.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
body.push_str(&numbered[..end]);
|
||||
body.push_str(MARKER);
|
||||
output_truncated = true;
|
||||
break;
|
||||
}
|
||||
body.push_str(&numbered);
|
||||
}
|
||||
Rendered {
|
||||
body,
|
||||
line_count: lines.len(),
|
||||
total_lines,
|
||||
truncated: start_line > 0 || truncated,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a slice of lines from `text` with `cat -n` style 1-based line
|
||||
/// numbers. Pure function — no I/O, no history touching.
|
||||
#[cfg(test)]
|
||||
fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered {
|
||||
let all_lines: Vec<&str> = text.lines().collect();
|
||||
let total_lines = all_lines.len();
|
||||
let start = offset.min(total_lines);
|
||||
let end = start.saturating_add(limit).min(total_lines);
|
||||
let slice = &all_lines[start..end];
|
||||
let line_count = slice.len();
|
||||
|
||||
use std::fmt::Write as _;
|
||||
let mut body = String::with_capacity(text.len().saturating_add(line_count * 8));
|
||||
for (i, line) in slice.iter().enumerate() {
|
||||
let lineno = start + i + 1;
|
||||
let _ = writeln!(&mut body, "{:>6}\t{}", lineno, line);
|
||||
}
|
||||
|
||||
Rendered {
|
||||
body,
|
||||
line_count,
|
||||
total_lines,
|
||||
truncated: start > 0 || end < total_lines,
|
||||
truncated: start_line > 0 || truncated || output_truncated,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +196,32 @@ mod tests {
|
||||
(dir, session, Tracker::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_numbering_and_unicode_expansion_keep_rendered_output_bounded() {
|
||||
for text in [
|
||||
"\n".repeat(PROVIDER_BYTE_LIMIT),
|
||||
"界".repeat(PROVIDER_BYTE_LIMIT),
|
||||
"\u{1}".repeat(PROVIDER_BYTE_LIMIT),
|
||||
] {
|
||||
let rendered = render_provider_read(&text, 0, text.lines().count(), false);
|
||||
assert!(rendered.body.len() <= PROVIDER_BYTE_LIMIT);
|
||||
assert!(serde_json::to_vec(&rendered.body).unwrap().len() < 2 * 1024 * 1024);
|
||||
}
|
||||
let rendered = render_provider_read(
|
||||
&"\n".repeat(PROVIDER_BYTE_LIMIT),
|
||||
0,
|
||||
PROVIDER_BYTE_LIMIT,
|
||||
false,
|
||||
);
|
||||
assert!(rendered.truncated);
|
||||
assert!(rendered.line_count < PROVIDER_BYTE_LIMIT);
|
||||
assert!(
|
||||
rendered
|
||||
.body
|
||||
.contains("truncated at rendered output byte limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_tool_basic_records_history() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Provider dispatch shared by native and ordinary search processing.
|
||||
//! Request construction/defaults and rendering remain in Glob/Grep; this only
|
||||
//! selects the provider surface and verifies the returned readonly result kind.
|
||||
use agen::tool::ToolError;
|
||||
use workdir::{
|
||||
CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult, GlobRequest, GlobResult,
|
||||
GrepRequest, GrepResult, WorkdirSessionHandle,
|
||||
};
|
||||
|
||||
use crate::{ToolsError, file_target::checked_error};
|
||||
|
||||
pub(crate) struct SearchTarget {
|
||||
session: WorkdirSessionHandle,
|
||||
checkout: bool,
|
||||
}
|
||||
|
||||
impl SearchTarget {
|
||||
pub fn tool(session: WorkdirSessionHandle) -> Self {
|
||||
Self {
|
||||
session,
|
||||
checkout: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checkout(session: WorkdirSessionHandle) -> Self {
|
||||
Self {
|
||||
session,
|
||||
checkout: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn glob(&self, request: GlobRequest) -> Result<GlobResult, ToolError> {
|
||||
if self.checkout {
|
||||
let result = self
|
||||
.session
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(
|
||||
request,
|
||||
)))
|
||||
.await
|
||||
.map_err(checked_error)?;
|
||||
glob_result(result)
|
||||
} else {
|
||||
self.session
|
||||
.glob(request)
|
||||
.await
|
||||
.map_err(|error| ToolsError::from(error).into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn grep(&self, request: GrepRequest) -> Result<GrepResult, ToolError> {
|
||||
if self.checkout {
|
||||
let result = self
|
||||
.session
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(
|
||||
request,
|
||||
)))
|
||||
.await
|
||||
.map_err(checked_error)?;
|
||||
grep_result(result)
|
||||
} else {
|
||||
self.session
|
||||
.grep(request)
|
||||
.await
|
||||
.map_err(|error| ToolsError::from(error).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn glob_result(result: CheckoutSearchResult) -> Result<GlobResult, ToolError> {
|
||||
match result {
|
||||
CheckoutSearchResult::Glob(result) => Ok(result),
|
||||
_ => Err(crate::file_target::readonly_result_error("Glob")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn grep_result(result: CheckoutSearchResult) -> Result<GrepResult, ToolError> {
|
||||
match result {
|
||||
CheckoutSearchResult::Grep(result) => Ok(result),
|
||||
_ => Err(crate::file_target::readonly_result_error("Grep")),
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,25 @@ impl Tracker {
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Record the full provider content hash and total line count, even for a sliced Read.
|
||||
/// Keeping both under one lock prevents pairing counts from a different observation.
|
||||
pub fn record_workdir_observation(
|
||||
&self,
|
||||
path: &workdir::WorkdirPath,
|
||||
hash: workdir::ContentHash,
|
||||
total_lines: usize,
|
||||
) {
|
||||
let key = self.key(PathBuf::from(path.as_str()));
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
inner.line_counts.insert(key.clone(), total_lines);
|
||||
inner.hashes.insert(key.clone(), hash);
|
||||
inner.recency.retain(|candidate| candidate != &key);
|
||||
inner.recency.push_front(key);
|
||||
if inner.recency.len() > RECENCY_CAPACITY {
|
||||
inner.recency.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_workdir_hash(&self, path: &workdir::WorkdirPath, hash: workdir::ContentHash) {
|
||||
let key = self.key(PathBuf::from(path.as_str()));
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
+64
-38
@@ -9,10 +9,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::tracker::Tracker;
|
||||
use workdir::{
|
||||
StatRequest, WorkdirError, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter,
|
||||
WriteRequest,
|
||||
};
|
||||
use workdir::{StatRequest, WorkdirError, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
|
||||
const DESCRIPTION: &str = "Create a new file or overwrite an existing one in the selected Workdir attachment. \
|
||||
Missing parent directories within scope are created automatically. Existing files must have been read first \
|
||||
@@ -53,50 +50,79 @@ impl Tool for WriteTool {
|
||||
.tracker
|
||||
.scoped_attachment(&selected.alias, selected.generation);
|
||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
tracing::debug!(path = %path, bytes = params.content.len(), "Write");
|
||||
Ok(execute_write(
|
||||
crate::file_target::FileTarget {
|
||||
session: selected.session,
|
||||
path,
|
||||
validator: None,
|
||||
},
|
||||
tracker,
|
||||
params.content,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
.await?
|
||||
.output)
|
||||
}
|
||||
}
|
||||
|
||||
let mutation_key = PathBuf::from(path.as_str());
|
||||
let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await;
|
||||
let expected_hash = match selected
|
||||
/// `create_path` is a checked parent-relative create-new target, never an overwrite.
|
||||
pub(crate) async fn execute_write(
|
||||
target: crate::file_target::FileTarget,
|
||||
tracker: Tracker,
|
||||
content: String,
|
||||
create_path: Option<WorkdirPath>,
|
||||
ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
let path = create_path.as_ref().unwrap_or(&target.path);
|
||||
tracing::debug!(path = %path, bytes = content.len(), "Write");
|
||||
let mutation_key = PathBuf::from(path.as_str());
|
||||
let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await;
|
||||
let expected_hash = if create_path.is_some() {
|
||||
None
|
||||
} else if target.validator.is_some() {
|
||||
// Native Write is existing-file-only, and always requires a prior observation.
|
||||
Some(tracker.expected_workdir_hash(path)?)
|
||||
} else {
|
||||
match target
|
||||
.session
|
||||
.stat(StatRequest { path: path.clone() })
|
||||
.await
|
||||
{
|
||||
Ok(_) => Some(tracker.expected_workdir_hash(&path)?),
|
||||
Ok(_) => Some(tracker.expected_workdir_hash(path)?),
|
||||
Err(WorkdirError::NotFound(_)) => None,
|
||||
Err(error) => return Err(ToolsError::from(error).into()),
|
||||
};
|
||||
|
||||
let old_line_count = tracker.observed_workdir_line_count(&path).unwrap_or(0);
|
||||
let outcome = selected
|
||||
.session
|
||||
.write(WriteRequest {
|
||||
path: path.clone(),
|
||||
content: params.content.as_bytes().to_vec(),
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
|
||||
tracker.record_change(params.content.lines().count(), old_line_count);
|
||||
tracker.record_workdir_content(&path, params.content.as_bytes());
|
||||
|
||||
let summary = format!(
|
||||
"{} {} ({} bytes)",
|
||||
if outcome.created {
|
||||
"Created"
|
||||
} else {
|
||||
"Overwrote"
|
||||
},
|
||||
path,
|
||||
outcome.bytes_written
|
||||
);
|
||||
Ok(ToolOutput {
|
||||
}
|
||||
};
|
||||
let old_line_count = tracker.observed_workdir_line_count(path).unwrap_or(0);
|
||||
let (outcome, validator) = target
|
||||
.write(
|
||||
content.as_bytes().to_vec(),
|
||||
expected_hash,
|
||||
create_path.clone(),
|
||||
)
|
||||
.await?;
|
||||
tracker.record_change(content.lines().count(), old_line_count);
|
||||
tracker.record_workdir_content(path, content.as_bytes());
|
||||
let summary = format!(
|
||||
"{} {} ({} bytes)",
|
||||
if outcome.created {
|
||||
"Created"
|
||||
} else {
|
||||
"Overwrote"
|
||||
},
|
||||
path,
|
||||
outcome.bytes_written
|
||||
);
|
||||
Ok(crate::checkout::CheckoutToolOutput {
|
||||
output: ToolOutput {
|
||||
summary,
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
},
|
||||
paths: create_path.into_iter().collect(),
|
||||
validator,
|
||||
})
|
||||
}
|
||||
|
||||
/// Factory for the `Write` tool bound to one compatibility session.
|
||||
|
||||
Reference in New Issue
Block a user