feat: project attached checkouts with scoped native AI operations

This commit is contained in:
2026-10-06 03:37:07 +09:00
parent 3deef49897
commit 149853bb0e
43 changed files with 8706 additions and 346 deletions
+53
View File
@@ -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)
+252
View File
@@ -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, &params.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(&params.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) = &params.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;
+562
View File
@@ -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
View File
@@ -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(&params.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(&params.new_string, &params.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(&params.new_string, &params.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
+18 -1
View File
@@ -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(
+186
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+4
View File
@@ -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
View File
@@ -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(&params.file_path).map_err(ToolsError::from)?,
validator: None,
},
tracker,
params,
)
.await?
.output)
}
}
let path = WorkdirPath::new_scoped(&params.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();
+81
View File
@@ -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")),
}
}
+19
View File
@@ -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
View File
@@ -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(&params.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.