Skip to content

Commit 5c7d195

Browse files
committed
feat(tools): add context-efficient repository operations
1 parent 8440773 commit 5c7d195

13 files changed

Lines changed: 1492 additions & 47 deletions

File tree

README.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ the model.
207207

208208
| Concern | Built-in surface |
209209
| --- | --- |
210-
| Files and directories | `read`, `write`, `edit`, `patch`, `ls`, `glob`, `grep` with ranged or resumable operations and compare-and-swap mutation |
210+
| Files and directories | Budgeted single/multi-file `read`, `write`, previewable CAS `edit`, `patch`, `ls`, order-selectable paginated `glob`, and mode-selectable `grep` |
211211
| Commands and source control | Bounded `bash` plus typed `git` operations, cancellation, and Unix process-group termination |
212212
| Code intelligence | `code_symbols`, `code_navigation`, and `code_diagnostics`; source reading and mutation remain in file tools |
213213
| Web evidence | Multi-engine `web_search` and bounded `web_fetch` with structured failures, fallback, source normalization, and SSRF protections |
@@ -219,6 +219,51 @@ idempotent, resumable, cancellation-safe, paginated, output-kind, and parallel
219219
limits. `batch` parallelizes only calls that declare safe read-only behavior;
220220
mutations and unknown tools are serialized.
221221

222+
### Context-efficient repository tools
223+
224+
`read` can pack 1-32 known text files into one ordered response. The shared
225+
budget includes headers and the continuation itself, so the result reaches the
226+
model intact instead of relying on downstream truncation:
227+
228+
```json
229+
{
230+
"files": [
231+
{ "path": "src/lib.rs" },
232+
{ "path": "src/config.rs", "offset": 40, "limit": 80 }
233+
],
234+
"max_output_bytes": 65536
235+
}
236+
```
237+
238+
If the budget fills, copy `metadata.batch.continuation` back into `files`.
239+
Offsets and remaining per-file limits are advanced without repeating completed
240+
lines. One missing or unreadable member is reported in its own segment while
241+
the other files continue.
242+
243+
`grep.output_mode` controls how much evidence enters the context:
244+
245+
| Mode | Result |
246+
| --- | --- |
247+
| `content` | Matching lines with optional context (default) |
248+
| `files_with_matches` | Lexically cursor-paginated matching paths only |
249+
| `count` | Lexically cursor-paginated matching-line counts per file |
250+
| `summary` | Full-scan line and file totals without rendered matches |
251+
252+
The non-content modes ask built-in workspace backends to count matches without
253+
constructing discarded match text. `glob` retains a backend's recency or
254+
relevance order by default; request `sort: "path"` when cursor pages require
255+
stable lexical ordering.
256+
257+
Use `edit` with `dry_run: true` to receive the exact before/after diff without
258+
writing. The dry run is declared read-only and can be safely batched. Apply the
259+
result with `expected_replacements` and optionally `max_replacements` to reject
260+
stale or unexpectedly broad changes before the compare-and-swap write.
261+
262+
These repository-context ergonomics were informed by
263+
[FastCtx](https://github.com/yc-duan/fastctx). Their implementation here stays
264+
inside A3S Code's workspace abstractions, capability declarations, permission
265+
path, remote-backend contracts, and structured metadata.
266+
222267
### Sandbox and credential boundaries
223268

224269
Hosts can attach a `BashSandbox`. The fail-closed local `SrtBashSandbox` limits

core/src/tools/builtin/edit.rs

Lines changed: 206 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@ impl Tool for EditTool {
3737
"replace_all": {
3838
"type": "boolean",
3939
"description": "Optional. Replace all occurrences. Default: false."
40+
},
41+
"dry_run": {
42+
"type": "boolean",
43+
"description": "Optional. Preview the exact diff and replacement count without writing. Default: false."
44+
},
45+
"max_replacements": {
46+
"type": "integer",
47+
"minimum": 1,
48+
"description": "Optional safety ceiling. Reject the edit before writing when it would replace more occurrences."
49+
},
50+
"expected_replacements": {
51+
"type": "integer",
52+
"minimum": 1,
53+
"description": "Optional compare guard. Reject the edit before writing unless the observed replacement count is exactly this value."
4054
}
4155
},
4256
"required": ["file_path", "old_string", "new_string"],
@@ -50,14 +64,24 @@ impl Tool for EditTool {
5064
"file_path": "src/lib.rs",
5165
"old_string": "foo",
5266
"new_string": "bar",
53-
"replace_all": true
67+
"replace_all": true,
68+
"dry_run": true,
69+
"max_replacements": 20
5470
}
5571
]
5672
})
5773
}
5874

59-
fn capabilities(&self, _args: &serde_json::Value) -> crate::tools::ToolCapabilities {
60-
let mut capabilities = crate::tools::ToolCapabilities::conservative();
75+
fn capabilities(&self, args: &serde_json::Value) -> crate::tools::ToolCapabilities {
76+
let mut capabilities = if args
77+
.get("dry_run")
78+
.and_then(serde_json::Value::as_bool)
79+
.unwrap_or(false)
80+
{
81+
crate::tools::ToolCapabilities::parallel_safe_read(16)
82+
} else {
83+
crate::tools::ToolCapabilities::conservative()
84+
};
6185
capabilities.output_kind = crate::tools::ToolOutputKind::Diff;
6286
capabilities
6387
}
@@ -77,11 +101,26 @@ impl Tool for EditTool {
77101
Some(s) => s,
78102
None => return Ok(ToolOutput::error("new_string parameter is required")),
79103
};
104+
if old_string.is_empty() {
105+
return Ok(ToolOutput::error("old_string must not be empty"));
106+
}
80107

81108
let replace_all = args
82109
.get("replace_all")
83110
.and_then(|v| v.as_bool())
84111
.unwrap_or(false);
112+
let dry_run = args
113+
.get("dry_run")
114+
.and_then(|v| v.as_bool())
115+
.unwrap_or(false);
116+
let max_replacements = match positive_usize_arg(args, "max_replacements") {
117+
Ok(value) => value,
118+
Err(error) => return Ok(ToolOutput::error(error)),
119+
};
120+
let expected_replacements = match positive_usize_arg(args, "expected_replacements") {
121+
Ok(value) => value,
122+
Err(error) => return Ok(ToolOutput::error(error)),
123+
};
85124

86125
let workspace_path = match ctx.resolve_workspace_path(file_path) {
87126
Ok(path) => path,
@@ -116,31 +155,68 @@ impl Tool for EditTool {
116155
)));
117156
}
118157

158+
let replacement_count = if replace_all { count } else { 1 };
159+
if let Some(expected) = expected_replacements {
160+
if replacement_count != expected {
161+
return Ok(ToolOutput::error(format!(
162+
"Replacement count mismatch in {display_path}: expected {expected} replacement(s), found {replacement_count}. Re-run dry_run to inspect the current file."
163+
))
164+
.with_metadata(serde_json::json!({
165+
"file_path": file_path,
166+
"dry_run": dry_run,
167+
"expected_replacements": expected,
168+
"replacement_count": replacement_count,
169+
})));
170+
}
171+
}
172+
if let Some(maximum) = max_replacements {
173+
if replacement_count > maximum {
174+
return Ok(ToolOutput::error(format!(
175+
"Edit would replace {replacement_count} occurrence(s) in {display_path}, which exceeds max_replacements={maximum}."
176+
))
177+
.with_metadata(serde_json::json!({
178+
"file_path": file_path,
179+
"dry_run": dry_run,
180+
"max_replacements": maximum,
181+
"replacement_count": replacement_count,
182+
})));
183+
}
184+
}
185+
119186
let new_content = if replace_all {
120187
content.replace(old_string, new_string)
121188
} else {
122189
content.replacen(old_string, new_string, 1)
123190
};
124191

192+
let change_metadata = || {
193+
serde_json::json!({
194+
"file_path": file_path,
195+
"before": content,
196+
"after": new_content,
197+
"dry_run": dry_run,
198+
"replacement_count": replacement_count,
199+
"expected_replacements": expected_replacements,
200+
"max_replacements": max_replacements,
201+
})
202+
};
203+
if dry_run {
204+
return Ok(ToolOutput::success(format!(
205+
"Dry run: would replace {replacement_count} occurrence(s) in {display_path}; nothing was written"
206+
))
207+
.with_metadata(change_metadata()));
208+
}
209+
125210
match ctx
126211
.workspace_services
127212
.write_for_edit(&workspace_path, &new_content, version.as_deref())
128213
.await
129214
{
130-
Ok(_) => {
131-
// Attach diff metadata so frontend can show Monaco diff
132-
let mut metadata = serde_json::Map::new();
133-
metadata.insert("file_path".to_string(), serde_json::json!(file_path));
134-
metadata.insert("before".to_string(), serde_json::json!(content));
135-
metadata.insert("after".to_string(), serde_json::json!(new_content));
136-
137-
Ok(ToolOutput::success(format!(
138-
"Replaced {} occurrence(s) in {}",
139-
if replace_all { count } else { 1 },
140-
display_path
141-
))
142-
.with_metadata(serde_json::Value::Object(metadata)))
143-
}
215+
Ok(_) => Ok(ToolOutput::success(format!(
216+
"Replaced {} occurrence(s) in {}",
217+
replacement_count, display_path
218+
))
219+
.with_metadata(change_metadata())),
144220
Err(e) => {
145221
// Surface the typed kind via ToolOutput.error_kind so SDK
146222
// callers can react programmatically; the human-readable
@@ -164,6 +240,21 @@ impl Tool for EditTool {
164240
}
165241
}
166242

243+
fn positive_usize_arg(
244+
args: &serde_json::Value,
245+
name: &str,
246+
) -> std::result::Result<Option<usize>, String> {
247+
match args.get(name) {
248+
Some(value) => value
249+
.as_u64()
250+
.and_then(|value| usize::try_from(value).ok())
251+
.filter(|value| *value > 0)
252+
.map(Some)
253+
.ok_or_else(|| format!("{name} must be a positive integer")),
254+
None => Ok(None),
255+
}
256+
}
257+
167258
#[cfg(test)]
168259
mod tests {
169260
use super::*;
@@ -219,6 +310,95 @@ mod tests {
219310
assert_eq!(content, "ccc bbb ccc");
220311
}
221312

313+
#[tokio::test]
314+
async fn test_edit_dry_run_returns_diff_without_writing() {
315+
let temp = tempfile::tempdir().unwrap();
316+
let path = temp.path().join("test.txt");
317+
std::fs::write(&path, "alpha alpha").unwrap();
318+
let ctx = ToolContext::new(temp.path().to_path_buf());
319+
320+
let result = EditTool
321+
.execute(
322+
&serde_json::json!({
323+
"file_path": "test.txt",
324+
"old_string": "alpha",
325+
"new_string": "beta",
326+
"replace_all": true,
327+
"dry_run": true
328+
}),
329+
&ctx,
330+
)
331+
.await
332+
.unwrap();
333+
334+
assert!(result.success, "{}", result.content);
335+
assert_eq!(std::fs::read_to_string(path).unwrap(), "alpha alpha");
336+
let metadata = result.metadata.unwrap();
337+
assert_eq!(metadata["dry_run"], true);
338+
assert_eq!(metadata["replacement_count"], 2);
339+
assert_eq!(metadata["before"], "alpha alpha");
340+
assert_eq!(metadata["after"], "beta beta");
341+
assert!(
342+
EditTool
343+
.capabilities(&serde_json::json!({"dry_run": true}))
344+
.read_only
345+
);
346+
}
347+
348+
#[tokio::test]
349+
async fn test_edit_expected_replacements_mismatch_does_not_write() {
350+
let temp = tempfile::tempdir().unwrap();
351+
let path = temp.path().join("test.txt");
352+
std::fs::write(&path, "alpha alpha").unwrap();
353+
let ctx = ToolContext::new(temp.path().to_path_buf());
354+
355+
let result = EditTool
356+
.execute(
357+
&serde_json::json!({
358+
"file_path": "test.txt",
359+
"old_string": "alpha",
360+
"new_string": "beta",
361+
"replace_all": true,
362+
"expected_replacements": 3
363+
}),
364+
&ctx,
365+
)
366+
.await
367+
.unwrap();
368+
369+
assert!(!result.success);
370+
assert!(result
371+
.content
372+
.contains("expected 3 replacement(s), found 2"));
373+
assert_eq!(std::fs::read_to_string(path).unwrap(), "alpha alpha");
374+
}
375+
376+
#[tokio::test]
377+
async fn test_edit_max_replacements_blocks_oversized_change() {
378+
let temp = tempfile::tempdir().unwrap();
379+
let path = temp.path().join("test.txt");
380+
std::fs::write(&path, "alpha alpha alpha").unwrap();
381+
let ctx = ToolContext::new(temp.path().to_path_buf());
382+
383+
let result = EditTool
384+
.execute(
385+
&serde_json::json!({
386+
"file_path": "test.txt",
387+
"old_string": "alpha",
388+
"new_string": "beta",
389+
"replace_all": true,
390+
"max_replacements": 2
391+
}),
392+
&ctx,
393+
)
394+
.await
395+
.unwrap();
396+
397+
assert!(!result.success);
398+
assert!(result.content.contains("exceeds max_replacements=2"));
399+
assert_eq!(std::fs::read_to_string(path).unwrap(), "alpha alpha alpha");
400+
}
401+
222402
#[tokio::test]
223403
async fn test_edit_not_unique() {
224404
let temp = tempfile::tempdir().unwrap();
@@ -279,6 +459,15 @@ mod tests {
279459
let examples = params["examples"].as_array().unwrap();
280460
assert_eq!(examples[0]["file_path"], "src/lib.rs");
281461
assert!(examples[0].get("path").is_none());
462+
assert_eq!(params["properties"]["dry_run"]["type"], "boolean");
463+
assert_eq!(
464+
params["properties"]["max_replacements"]["minimum"],
465+
serde_json::json!(1)
466+
);
467+
assert_eq!(
468+
params["properties"]["expected_replacements"]["minimum"],
469+
serde_json::json!(1)
470+
);
282471
}
283472

284473
#[tokio::test]

0 commit comments

Comments
 (0)