Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 60 additions & 13 deletions Sources/SwiftAgentKit/Context/ArtifactTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,30 @@ public struct ArtifactReadTool: AgentTool {
}
}

/// Search within a stored tool output for lines matching a substring.
/// Search within a stored tool output for lines matching one or more
/// substrings, with surrounding context lines.
public struct ArtifactSearchTool: AgentTool {
public let name = "artifact_search"
public let description = """
Search a previous tool call's full output for lines containing a substring. \
Returns matching line numbers and text. Use the artifact id from the tool \
ledger.
Search a previous tool call's full output for lines containing substrings. \
Pass ALL the terms you want to check in ONE call via `queries` (e.g. \
["error:", "TEST FAILED", "warning"]) instead of one call per term. Returns \
matching lines with 2 lines of context each. Use the artifact id from the \
tool ledger.
"""
public let parameters = ToolParameters(
properties: [
"artifact_id": ToolParameterProperty(type: "string", description: "The artifact id, e.g. artifact-abc123"),
"query": ToolParameterProperty(type: "string", description: "Substring to search for (case-insensitive)"),
"max_matches": ToolParameterProperty(type: "integer", description: "Maximum matches to return (default 10)"),
"queries": ToolParameterProperty(type: "array", description: "Substrings to search for (case-insensitive) — batch every term you want to check into one call", itemsType: "string"),
"query": ToolParameterProperty(type: "string", description: "Single substring to search for (alternative to `queries`)"),
"max_matches": ToolParameterProperty(type: "integer", description: "Maximum matches per query (default 10)"),
],
required: ["artifact_id", "query"]
required: ["artifact_id"]
)

/// Context lines shown above and below each match.
static let contextLines = 2

private let store: any ArtifactStore

public init(store: any ArtifactStore) {
Expand All @@ -77,18 +84,58 @@ public struct ArtifactSearchTool: AgentTool {
guard let id = parameters["artifact_id"] as? String, !id.isEmpty else {
return .error(toolCallId: "", toolName: name, message: "artifact_search requires an artifact_id.")
}
guard let query = parameters["query"] as? String, !query.isEmpty else {
return .error(toolCallId: "", toolName: name, message: "artifact_search requires a query.")
var queries: [String] = []
if let list = parameters["queries"] as? [Any] {
queries = list.compactMap { $0 as? String }.filter { !$0.isEmpty }
}
if let single = parameters["query"] as? String, !single.isEmpty {
queries.append(single)
}
guard !queries.isEmpty else {
return .error(toolCallId: "", toolName: name, message: "artifact_search requires `queries` (array) or `query` (string).")
}
let maxMatches = intValue(parameters["max_matches"]) ?? 10

let matches = await store.search(id, query: query, maxMatches: maxMatches)
if matches.isEmpty {
return .success(toolCallId: "", toolName: name, result: "No matches for \"\(query)\" in \(id).")
guard let artifact = await store.get(id) else {
return .error(toolCallId: "", toolName: name, message: "Unknown artifact: \(id)")
}
let rendered = matches.map { "L\($0.line): \($0.text)" }.joined(separator: "\n")
let rendered = Self.render(content: artifact.content,
queries: queries,
maxMatchesPerQuery: maxMatches,
artifactID: id)
return .success(toolCallId: "", toolName: name, result: rendered)
}

/// Render grouped, contextual matches for each query. Pure so it's testable.
static func render(content: String, queries: [String], maxMatchesPerQuery: Int, artifactID: String) -> String {
let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
let lowered = lines.map { $0.lowercased() }
var sections: [String] = []
for query in queries {
let needle = query.lowercased()
var matchIndices: [Int] = []
for (index, line) in lowered.enumerated() where line.contains(needle) {
matchIndices.append(index)
if matchIndices.count >= maxMatchesPerQuery { break }
}
if matchIndices.isEmpty {
sections.append("\"\(query)\": no matches")
continue
}
var blocks: [String] = []
for match in matchIndices {
let lo = max(0, match - contextLines)
let hi = min(lines.count - 1, match + contextLines)
let block = (lo...hi).map { i in
let marker = i == match ? ">" : " "
return "\(marker) L\(i + 1): \(lines[i].prefix(500))"
}.joined(separator: "\n")
blocks.append(block)
}
sections.append("\"\(query)\" — \(matchIndices.count) match\(matchIndices.count == 1 ? "" : "es"):\n" + blocks.joined(separator: "\n…\n"))
}
return "Results in \(artifactID):\n\n" + sections.joined(separator: "\n\n")
}
}

// Tool arguments arrive as Int or Double depending on JSON decoding.
Expand Down
11 changes: 9 additions & 2 deletions Sources/SwiftAgentKit/Context/ContextManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,15 @@ public final class ContextManager: @unchecked Sendable {
cacheActiveArtifact(result.toolCallId, artifact.id)
artifactID = artifact.id
}
let preview = String(result.result.prefix(maxActiveResultChars))
return "[Tool: \(name)] \(status)\n\(preview)\n… [truncated — full output in artifact \(artifactID); use artifact_read]"
// Build/test logs put the verdict at the END (a compiler head is
// boilerplate); a head-only preview hides it and sends the model
// grepping the artifact keyword-by-keyword. Keep a head AND a tail,
// cutting in the middle — same reasoning as the receipt summarizer.
let headLen = maxActiveResultChars / 3
let tailLen = maxActiveResultChars - headLen
let head = String(result.result.prefix(headLen))
let tail = String(result.result.suffix(tailLen))
return "[Tool: \(name)] \(status)\n\(head)\n… [middle truncated — full output in artifact \(artifactID); use artifact_read or artifact_search] …\n\(tail)"
}

private func cachedActiveArtifact(_ callID: String) -> String? {
Expand Down
66 changes: 66 additions & 0 deletions Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2420,3 +2420,69 @@ func liveAgentRecallsToolConclusionAfterCompaction() async throws {
// …but the task statement survives it.
#expect(forLLM.contains { $0.role == .user && $0.content.contains("Gold Dollar") })
}

// MARK: - Artifact triage ergonomics

@Test func testActiveDisplayPreviewKeepsTail() async {
// xcodebuild-style output: boilerplate head, verdict at the END. The active
// preview must include the tail — a head-only preview hides the verdict and
// sends the model grepping the artifact keyword-by-keyword.
let manager = ContextManager(maxActiveResultChars: 120, inlineBudgetChars: 0)
let log = "Command line invocation: /Applications/Xcode.app/...\n"
+ String(repeating: "CompileSwift normal arm64 SomeFile.swift\n", count: 50)
+ "** TEST FAILED **"
let messages: [AgentMessage] = [
.user("run the tests"),
.assistant(content: "", toolCalls: [AgentToolCall(id: "c1", name: "run_shell")]),
.tool(results: [.success(toolCallId: "c1", toolName: "run_shell", result: log)]),
]

let out = await manager.modelMessages(messages) { $0 }

let toolMsg = out.first { $0.role == .tool }
#expect(toolMsg?.content.contains("** TEST FAILED **") == true) // tail preserved
#expect(toolMsg?.content.contains("Command line invocation") == true) // head preserved
#expect(toolMsg?.content.contains("middle truncated") == true) // cut is in the middle
}

@Test func testArtifactSearchBatchesMultipleQueriesWithContext() async throws {
let store = InMemoryArtifactStore()
let content = (1...20).map { "line \($0)" }.joined(separator: "\n")
.replacingOccurrences(of: "line 10", with: "error: something broke")
let artifact = await store.save(content, description: "test", toolCallID: nil)
let tool = ArtifactSearchTool(store: store)

let result = try await tool.execute(parameters: [
"artifact_id": artifact.id,
"queries": ["ERROR:", "TEST FAILED"],
])

#expect(!result.isError)
// Case-insensitive hit, with the match marked and ±2 context lines…
#expect(result.result.contains("> L10: error: something broke"))
#expect(result.result.contains("L8: line 8"))
#expect(result.result.contains("L12: line 12"))
// …and the miss reported in the same single call.
#expect(result.result.contains("\"TEST FAILED\": no matches"))
}

@Test func testArtifactSearchSingleQueryStillWorks() async throws {
let store = InMemoryArtifactStore()
let artifact = await store.save("alpha\nbeta\ngamma", description: "t", toolCallID: nil)
let tool = ArtifactSearchTool(store: store)

let result = try await tool.execute(parameters: ["artifact_id": artifact.id, "query": "beta"])

#expect(!result.isError)
#expect(result.result.contains("> L2: beta"))
}

@Test func testArtifactSearchRequiresSomeQuery() async throws {
let store = InMemoryArtifactStore()
let artifact = await store.save("x", description: "t", toolCallID: nil)
let tool = ArtifactSearchTool(store: store)

let result = try await tool.execute(parameters: ["artifact_id": artifact.id])

#expect(result.isError)
}
Loading