Add scratch content search tool

This commit is contained in:
Kaede Akino committed 2026-08-29 01:38:00 +08:00
1 parent eb3a576a6d
commit 711c8b2fce
7 files changed
+524 -6

No files matched your search

+5 -2
View File
@@ -127,8 +127,11 @@ is why every scratch tool is parallel. `scratch_pull` and `scratch_push` move by
between a host and the workspace without the bytes entering the transcript, so a
remote file can be pulled, edited and pushed back without a copy of it crossing the
model boundary in either direction; `scratch_edit` returns a unified diff rather than
the new file for the same reason. The SFTP tools are unchanged and know nothing about
the workspace. Because a push is approved by filename rather than by content, its
the new file for the same reason. `scratch_search` recursively searches UTF-8 content
inside that same scope and returns bounded matching-line previews, so locating a small
region does not require reading a large staged file into the transcript. The SFTP tools
are unchanged and know nothing about the workspace. Because a push is approved by
filename rather than by content, its
preflight puts the size, digest and a redacted head into the approval reasons, and
binds the approval to that digest through execution-only metadata so a file rewritten
while the user was deciding is refused rather than written.
@@ -2,7 +2,7 @@ import Foundation
/// The agent's own staging area, exposed as tools.
///
/// Six of these are `target: .local` and cannot leave `AgentScratchWorkspace`'s
/// Seven of these are `target: .local` and cannot leave `AgentScratchWorkspace`'s
/// scope directory — not to a host, not to the network, not to any other part of
/// the user's disk. That containment is what earns `.locallyContained` on the three
/// that write: they change something, but only storage the app owns.
@@ -42,7 +42,7 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding {
transfers = []
}
return [
list(workspace), read(workspace), write(workspace),
list(workspace), read(workspace), search(workspace), write(workspace),
edit(workspace), diff(workspace), delete(workspace),
] + transfers + (web.map { [fetch(workspace, $0)] } ?? [])
}
@@ -129,6 +129,72 @@ nonisolated enum AgentScratchTools: AgentToolSchemaBuilding {
}
}
private static func search(_ workspace: AgentScratchWorkspace) -> AnyAgentTool {
AnyAgentTool(descriptor: descriptor(
"scratch_search",
"""
Search UTF-8 files in the scratch workspace one line at a time. Omit path to \
search the whole workspace, or pass a directory for a recursive search or one \
file to search only it. Literal matching is the default; set regex for a regular \
expression. Matching is smart-case unless case_sensitive is supplied: a query \
containing an uppercase letter is case-sensitive, and other queries are not. \
Binary files are skipped. Results contain bounded matching-line previews; use \
scratch_read around a returned line number for more context.
""",
properties: [
"query": string(max: AgentScratchWorkspace.Limits.searchQueryCharacters),
"path": string(max: pathBytes), "regex": boolean(),
"case_sensitive": boolean(),
"limit": integer(min: 1, max: AgentScratchWorkspace.Limits.searchResults),
],
required: ["query"], target: .local, safety: .locallyReadOnly,
concurrency: .parallel,
presentation: .init(
symbol: "magnifyingglass",
activity: .semanticArgument(key: "query", fallback: .scratchWorkspace),
output: .json, detailPresenterID: "builtin.scratch_search",
actionKind: .search
)
)) { invocation, _ in
let arguments = try Arguments(invocation)
let outcome = try await workspace.search(
arguments.string("query"), path: arguments.optionalString("path"),
regex: arguments.optionalBool("regex") ?? false,
caseSensitive: arguments.optionalBool("case_sensitive"),
limit: arguments.optionalInt("limit") ?? 20
)
var returned = outcome.matches
var truncated = outcome.isTruncated
var payload = searchPayload(outcome, matches: returned, truncated: truncated)
while payload.encodedString.utf8.count > contentBytes, !returned.isEmpty {
returned.removeLast()
truncated = true
payload = searchPayload(outcome, matches: returned, truncated: truncated)
}
return result(invocation, payload, truncated: truncated)
}
}
private static func searchPayload(
_ outcome: ScratchSearchOutcome, matches: [ScratchSearchMatch], truncated: Bool
) -> AgentJSONValue {
.object([
"matches": .array(matches.map { match in
.object([
"path": .string(match.path), "line": .number(Double(match.line)),
"text": .string(match.text),
"text_truncated": .bool(match.textTruncated),
])
}),
"matching_lines": .number(Double(outcome.matchingLines)),
"returned_matches": .number(Double(matches.count)),
"searched_files": .number(Double(outcome.searchedFiles)),
"skipped_binary_files": .number(Double(outcome.skippedBinaryFiles)),
"case_sensitive": .bool(outcome.caseSensitive),
"truncated": .bool(truncated),
])
}
private static func write(_ workspace: AgentScratchWorkspace) -> AnyAgentTool {
AnyAgentTool(descriptor: descriptor(
"scratch_write",
@@ -577,6 +643,9 @@ nonisolated extension AgentScratchTools {
static let readDetailPresenter = AgentToolDetailPresenter(
id: "builtin.scratch_read", present: presentReadDetail
)
static let searchDetailPresenter = AgentToolDetailPresenter(
id: "builtin.scratch_search", present: presentSearchDetail
)
static let writeDetailPresenter = AgentToolDetailPresenter(
id: "builtin.scratch_write", present: presentWriteDetail
)
@@ -668,6 +737,69 @@ nonisolated extension AgentScratchTools {
return items
}
private static func presentSearchDetail(
_ input: AgentToolDetailInput
) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
guard let object = input.result.objectValue else {
return F.genericItems(input.result, locale: input.locale)
}
let matching = object["matching_lines"]?.integerValue ?? 0
let returned = object["returned_matches"]?.integerValue ?? 0
let searched = object["searched_files"]?.integerValue ?? 0
let skipped = object["skipped_binary_files"]?.integerValue ?? 0
var items: [AgentToolDetail.Item] = [
.message(
String(
localized: "\(matching) matching lines in \(searched) files",
locale: input.locale
),
matching == 0 ? .secondary : .success
)
]
let rows = (object["matches"]?.arrayValue ?? []).compactMap {
match -> AgentToolDetail.ListRow? in
guard let fields = match.objectValue,
let path = fields["path"]?.stringValue,
let line = fields["line"]?.integerValue
else { return nil }
return .init(
title: "\(path):\(line)", subtitle: fields["text"]?.stringValue,
badges: fields["text_truncated"]?.boolValue == true
? [F.localized("Truncated", locale: input.locale)] : [],
symbol: "doc.text"
)
}
items.append(.list(.init(
title: nil, rows: rows,
emptyMessage: F.localized("No matches", locale: input.locale)
)))
if object["truncated"]?.boolValue == true {
items.append(.message(
String(
localized: "Showing \(returned) of \(matching) matching lines",
locale: input.locale
),
.warning
))
}
if skipped > 0 {
items.append(.message(
String(localized: "Skipped \(skipped) binary files", locale: input.locale),
.secondary
))
}
items.append(F.field(
"Case sensitive",
F.localized(
object["case_sensitive"]?.boolValue == true ? "Yes" : "No",
locale: input.locale
),
locale: input.locale
))
return items
}
private static func presentWriteDetail(
_ input: AgentToolDetailInput
) -> [AgentToolDetail.Item] {
@@ -45,6 +45,24 @@ nonisolated struct ScratchText: Hashable, Sendable {
var lineEnding: LineEnding
}
nonisolated struct ScratchSearchMatch: Hashable, Sendable {
var path: String
/// 1-based, matching `scratch_read`, diffs and editors.
var line: Int
var text: String
var textTruncated: Bool
}
nonisolated struct ScratchSearchOutcome: Hashable, Sendable {
var matches: [ScratchSearchMatch]
/// Matching lines in the whole search, including those past `limit`.
var matchingLines: Int
var searchedFiles: Int
var skippedBinaryFiles: Int
var caseSensitive: Bool
var isTruncated: Bool
}
/// One exact-text replacement, matched against the file as it was before any edit in
/// the same call was applied.
nonisolated struct ScratchEdit: Hashable, Sendable {
@@ -101,6 +119,9 @@ actor AgentScratchWorkspace {
/// `sftp_read`'s inline ceiling. A *file* may be far larger; reading it whole
/// is what the offset/limit window and `scratch_push` are for.
static let inlineReadBytes = 128 * 1_024
static let searchQueryCharacters = 4_096
static let searchLineBytes = 1_024
static let searchResults = 100
static let edits = 32
}
@@ -206,6 +227,88 @@ actor AgentScratchWorkspace {
return ScratchFile(path: path, data: data, sha256: Self.digest(data))
}
/// Search UTF-8 files one line at a time without putting whole files into the
/// transcript. A directory scope is always recursive; `nil` means the workspace
/// root. Non-text files are counted and skipped rather than making a mixed tree
/// impossible to search.
func search(
_ query: String, path: String? = nil, regex: Bool = false,
caseSensitive requestedCaseSensitivity: Bool? = nil, limit: Int = 20
) throws -> ScratchSearchOutcome {
guard !query.isEmpty else {
throw AgentToolError.invalidArguments("query is required.")
}
guard query.count <= Limits.searchQueryCharacters else {
throw AgentToolError.invalidArguments(
"query is past the limit of \(Limits.searchQueryCharacters) characters."
)
}
guard !query.contains("\n"), !query.contains("\r") else {
throw AgentToolError.invalidArguments(
"query must fit on one line; scratch_search does not match across lines."
)
}
guard (1...Limits.searchResults).contains(limit) else {
throw AgentToolError.invalidArguments(
"limit must be between 1 and \(Limits.searchResults)."
)
}
let caseSensitive = requestedCaseSensitivity ?? query.unicodeScalars.contains {
CharacterSet.uppercaseLetters.contains($0)
}
let pattern = regex ? query : NSRegularExpression.escapedPattern(for: query)
let expression: NSRegularExpression
do {
expression = try NSRegularExpression(
pattern: pattern, options: caseSensitive ? [] : [.caseInsensitive]
)
} catch {
throw AgentToolError.invalidArguments("query is not a valid regular expression.")
}
let root = try root()
let files = try searchFiles(at: path, in: root)
var matches: [ScratchSearchMatch] = []
var matchingLines = 0
var searchedFiles = 0
var skippedBinaryFiles = 0
var previewWasTruncated = false
for filePath in files {
let file = try data(at: filePath)
guard let raw = String(data: file.data, encoding: .utf8) else {
skippedBinaryFiles += 1
continue
}
searchedFiles += 1
let normalized = LineEnding.normalizedToLF(raw)
let (lines, _) = UnifiedDiff.split(normalized)
for (index, line) in lines.enumerated() {
let wholeLine = NSRange(line.startIndex..<line.endIndex, in: line)
guard let first = expression.firstMatch(
in: line, range: wholeLine
), let range = Range(first.range, in: line) else { continue }
matchingLines += 1
guard matches.count < limit else { continue }
let preview = Self.searchPreview(
of: line, around: range, maximumBytes: Limits.searchLineBytes
)
previewWasTruncated = previewWasTruncated || preview.truncated
matches.append(ScratchSearchMatch(
path: filePath, line: index + 1, text: preview.text,
textTruncated: preview.truncated
))
}
}
return ScratchSearchOutcome(
matches: matches, matchingLines: matchingLines,
searchedFiles: searchedFiles, skippedBinaryFiles: skippedBinaryFiles,
caseSensitive: caseSensitive,
isTruncated: previewWasTruncated || matchingLines > matches.count
)
}
// MARK: Writing
@discardableResult
@@ -505,6 +608,40 @@ actor AgentScratchWorkspace {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
private func searchFiles(at path: String?, in root: URL) throws -> [String] {
guard let path else {
var entries: [ScratchEntry] = []
try walk(root, root: root, recursive: true) { entries.append($0) }
return entries.filter { $0.kind == .file }.map(\.path).sorted()
}
let url = try resolve(path, in: root)
guard let values = try? url.resourceValues(
forKeys: [.isDirectoryKey, .isRegularFileKey]
) else { throw AgentToolError.scratchNotFound(path) }
if values.isRegularFile == true { return [Self.relative(url, to: root)] }
guard values.isDirectory == true else { throw AgentToolError.scratchNotFound(path) }
var entries: [ScratchEntry] = []
try walk(url, root: root, recursive: true) { entries.append($0) }
return entries.filter { $0.kind == .file }.map(\.path).sorted()
}
/// Keep the first match visible in a bounded UTF-8 window. The byte boundaries
/// are advanced to scalar boundaries so clipping never introduces replacement
/// characters into a valid text file.
private static func searchPreview(
of line: String, around match: Range<String.Index>, maximumBytes: Int
) -> (text: String, truncated: Bool) {
let bytes = Array(line.utf8)
guard bytes.count > maximumBytes else { return (line, false) }
let matchOffset = line[..<match.lowerBound].utf8.count
var start = max(0, min(bytes.count - maximumBytes, matchOffset - maximumBytes / 2))
while start < bytes.count, bytes[start] & 0xC0 == 0x80 { start += 1 }
var end = min(bytes.count, start + maximumBytes)
while end < bytes.count, bytes[end] & 0xC0 == 0x80 { end -= 1 }
let body = String(decoding: bytes[start..<end], as: UTF8.self)
return ((start > 0 ? "…" : "") + body + (end < bytes.count ? "…" : ""), true)
}
// MARK: - Matching
/// Finds the one region `needle` names, or explains why it named none or several.
@@ -110,6 +110,7 @@ nonisolated enum AgentBuiltInToolDetailPresenters {
AgentBuiltInTools.requestUserSecretDetailPresenter,
AgentScratchTools.listDetailPresenter,
AgentScratchTools.readDetailPresenter,
AgentScratchTools.searchDetailPresenter,
AgentScratchTools.writeDetailPresenter,
AgentScratchTools.editDetailPresenter,
AgentScratchTools.diffDetailPresenter,
+73 -1
View File
@@ -531,6 +531,24 @@
}
}
},
"%lld matching lines in %lld files" : {
"comment" : "A message that shows the number of matching lines and the number of files that were searched.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld matching lines in %2$lld files"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "在%2$lld个文件中找到%1$lld个匹配行"
}
}
}
},
"%lld Models Offered" : {
"localizations" : {
"zh-Hans" : {
@@ -2105,6 +2123,18 @@
}
}
},
"Case sensitive" : {
"comment" : "Label for a checkbox indicating whether the search is case sensitive.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "区分大小写"
}
}
}
},
"Certificate" : {
"localizations" : {
"zh-Hans" : {
@@ -6793,6 +6823,18 @@
}
}
},
"No matches" : {
"comment" : "Text displayed when there are no search results.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "无匹配项"
}
}
}
},
"No Matching Hosts" : {
"localizations" : {
"zh-Hans" : {
@@ -10029,6 +10071,24 @@
}
}
},
"Showing %lld of %lld matching lines" : {
"comment" : "A warning message that indicates that the search result count is not the same as the matching count.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Showing %1$lld of %2$lld matching lines"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在显示%1$lld/%2$lld个匹配行"
}
}
}
},
"Sidebar" : {
"localizations" : {
"zh-Hans" : {
@@ -10129,6 +10189,18 @@
}
}
},
"Skipped %lld binary files" : {
"comment" : "A description of the number of binary files that were skipped during a search.",
"isCommentAutoGenerated" : true,
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已跳过%lld个二进制文件"
}
}
}
},
"Snippet Name" : {
"comment" : "Label for the text field used to name a new snippet.",
"isCommentAutoGenerated" : true,
@@ -13573,4 +13645,4 @@
}
},
"version" : "1.2"
}
}
+138 -1
View File
@@ -53,6 +53,9 @@ struct AgentScratchTests {
// And it is not reported as content either, so nothing can act on it.
let listed = try await workspace.list(nil, recursive: true).map(\.path)
#expect(listed == ["anchor.txt"])
let searched = try await workspace.search("anchor")
#expect(searched.matches.map(\.path) == ["anchor.txt"])
#expect(searched.searchedFiles == 1)
}
@Test
@@ -148,6 +151,108 @@ struct AgentScratchTests {
#expect(try await workspace.data(at: "blob.bin").data == binary)
}
// MARK: - Searching
@Test
func searchSupportsLiteralRegexAndSmartCaseMatching() async throws {
let workspace = Fixture.workspace()
_ = try await workspace.write(
"a.txt", data: Data("Alpha alpha\nneedle twice needle\nserver_42\n".utf8)
)
_ = try await workspace.write(
"b.txt", data: Data("alpha\nserver-x\n".utf8)
)
let insensitive = try await workspace.search("alpha")
#expect(!insensitive.caseSensitive)
#expect(insensitive.matchingLines == 2)
#expect(insensitive.matches.map { "\($0.path):\($0.line)" } == ["a.txt:1", "b.txt:1"])
let smartCase = try await workspace.search("Alpha")
#expect(smartCase.caseSensitive)
#expect(smartCase.matches.map(\.path) == ["a.txt"])
let overridden = try await workspace.search("Alpha", caseSensitive: false)
#expect(!overridden.caseSensitive)
#expect(overridden.matchingLines == 2)
let regex = try await workspace.search(#"server_[0-9]+"#, regex: true)
#expect(regex.matches.map { "\($0.path):\($0.line)" } == ["a.txt:3"])
// Search results are matching lines, not occurrences within those lines.
let duplicate = try await workspace.search("needle")
#expect(duplicate.matchingLines == 1)
#expect(duplicate.matches.count == 1)
}
@Test
func searchScopesAndOrdersFilesAndSkipsBinaryContent() async throws {
let workspace = Fixture.workspace()
_ = try await workspace.write("z.txt", data: Data("hit at root\n".utf8))
_ = try await workspace.write("dir/b.txt", data: Data("before\r\nhit b\r\n".utf8))
_ = try await workspace.write("dir/a.txt", data: Data("hit a\n".utf8))
_ = try await workspace.write(".hidden", data: Data("hidden hit\n".utf8))
_ = try await workspace.write("dir/blob.bin", data: Data([0xFF, 0x00, 0x80]))
let directory = try await workspace.search("hit", path: "dir")
#expect(directory.matches.map { "\($0.path):\($0.line)" } == [
"dir/a.txt:1", "dir/b.txt:2",
])
#expect(directory.searchedFiles == 2)
#expect(directory.skippedBinaryFiles == 1)
let file = try await workspace.search("hit", path: "z.txt")
#expect(file.matches.map(\.path) == ["z.txt"])
#expect(file.searchedFiles == 1)
let wholeWorkspace = try await workspace.search("hit")
#expect(wholeWorkspace.matches.map(\.path) == [
".hidden", "dir/a.txt", "dir/b.txt", "z.txt",
])
#expect(wholeWorkspace.searchedFiles == 4)
#expect(wholeWorkspace.skippedBinaryFiles == 1)
}
@Test
func searchBoundsResultsAndKeepsTheMatchVisibleInLongLines() async throws {
let workspace = Fixture.workspace()
let longLine = String(repeating: "x", count: 5_000)
+ "target" + String(repeating: "y", count: 5_000)
_ = try await workspace.write(
"long.txt", data: Data((longLine + "\ntarget two\ntarget three\n").utf8)
)
let outcome = try await workspace.search("target", limit: 1)
#expect(outcome.matchingLines == 3)
#expect(outcome.matches.count == 1)
#expect(outcome.matches[0].text.contains("target"))
#expect(outcome.matches[0].textTruncated)
#expect(outcome.isTruncated)
}
@Test
func searchRejectsInvalidPatternsPathsAndBounds() async throws {
let workspace = Fixture.workspace()
_ = try await workspace.write("a.txt", data: Data("text\n".utf8))
for query in ["", "two\nlines"] {
await #expect(throws: (any Error).self) {
try await workspace.search(query)
}
}
await #expect(throws: (any Error).self) {
try await workspace.search("[", regex: true)
}
await #expect(throws: (any Error).self) {
try await workspace.search("text", path: "../outside")
}
await #expect(throws: (any Error).self) {
try await workspace.search("text", path: "missing")
}
await #expect(throws: (any Error).self) {
try await workspace.search("text", limit: 101)
}
}
// MARK: - Editing
@Test
@@ -420,7 +525,7 @@ struct AgentScratchTests {
let local = AgentScratchTools.make(workspace: workspace)
#expect(local.map(\.descriptor.name).sorted() == [
"scratch_delete", "scratch_diff", "scratch_edit",
"scratch_list", "scratch_read", "scratch_write",
"scratch_list", "scratch_read", "scratch_search", "scratch_write",
])
// Every purely local tool is parallel, and only the three that change
// something claim to.
@@ -432,6 +537,12 @@ struct AgentScratchTests {
== ["scratch_delete", "scratch_edit", "scratch_write"]
)
#expect(!local.contains { $0.descriptor.safety == .requiresAuthorization })
let search = try #require(local.first { $0.descriptor.name == "scratch_search" })
let properties = search.descriptor.inputSchema.objectValue?["properties"]?.objectValue
#expect(properties?.keys.sorted() == [
"case_sensitive", "limit", "path", "query", "regex",
])
#expect(search.descriptor.presentation?.actionKind == .search)
// Transfer is absent until a connection pool is supplied, on the same terms
// as `web_search`: a tool that cannot work is not offered.
@@ -510,6 +621,32 @@ struct AgentScratchTests {
)
}
@Test
func searchToolBoundsItsEncodedTranscriptResult() async throws {
let workspace = Fixture.workspace()
let line = String(repeating: "\u{0001}", count: 1_100) + "target"
let body = Array(repeating: line, count: 100).joined(separator: "\n")
_ = try await workspace.write("large.txt", data: Data(body.utf8))
let tool = try #require(
AgentScratchTools.make(workspace: workspace)
.first { $0.descriptor.name == "scratch_search" }
)
let result = try await tool.execute(
Fixture.invocation("scratch_search", [
"query": .string("target"), "limit": .number(100),
]), context: Fixture.context()
)
let payload = try #require(result.untrustedPayload)
let object = try #require(
try AgentJSONValue.decode(Data(payload.utf8)).objectValue
)
#expect(payload.utf8.count <= 128 * 1_024)
#expect(result.isTruncated)
#expect(object["matching_lines"]?.integerValue == 100)
#expect((object["returned_matches"]?.integerValue ?? 100) < 100)
#expect(object["truncated"]?.boolValue == true)
}
// MARK: - Transfer
@Test
@@ -663,6 +663,42 @@ struct AgentToolPresentationTests {
#expect(rendered.contains("Docs"))
}
@Test
func scratchSearchDetailIsStructuredWithoutJSONSyntax() throws {
var card = makeCard(
name: "scratch_search", target: .local,
arguments: ["query": .string("listen")],
presentation: .init(
symbol: "magnifyingglass",
activity: .semanticArgument(key: "query", fallback: .scratchWorkspace),
output: .json, detailPresenterID: "builtin.scratch_search",
actionKind: .search
)
)
card.result = AgentToolResult(
callID: "call",
content: AgentJSONValue.object([
"matches": .array([
.object([
"path": .string("nginx.conf"), "line": .number(12),
"text": .string("listen 443 ssl;"),
"text_truncated": .bool(false),
]),
]),
"matching_lines": .number(1), "returned_matches": .number(1),
"searched_files": .number(2), "skipped_binary_files": .number(1),
"case_sensitive": .bool(false), "truncated": .bool(false),
]).encodedString
)
let detail = try #require(AgentToolPresentation.detail(for: card))
let rendered = String(describing: detail)
#expect(rendered.contains("nginx.conf:12"))
#expect(rendered.contains("listen 443 ssl;"))
#expect(!rendered.contains("{"))
#expect(!rendered.contains("}"))
}
@Test
func finishedBatchSummaryCountsRepeatedActivitiesAndPreservesFailures() {
let questionPresentation = AgentBuiltInTools.requestUserInput().descriptor.presentation