Add scratch content search tool
This commit is contained in:
1 parent
eb3a576a6d
commit
711c8b2fce
7 files changed
+524
-6
No files matched your search
+5
-2
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user