From 655f37733a1230e385ef57977e6c8197897de393 Mon Sep 17 00:00:00 2001 From: Ayman Hamed Date: Sun, 23 Aug 2026 19:30:18 +0300 Subject: [PATCH] Artifact triage ergonomics: head+tail previews, batched contextual search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Truncated active tool results now preview the head AND the tail — build/test logs put the verdict at the end, and a head-only preview sent models on long artifact_search fishing expeditions (observed: ~40 blind greps per xcodebuild failure). artifact_search now accepts a batched `queries` array (single `query` still works), searches via the artifact content directly, and returns each match with ±2 context lines and a per-query no-match report — one round trip instead of one call per keyword. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SwiftAgentKit/Context/ArtifactTools.swift | 73 +++++++++++++++---- .../Context/ContextManager.swift | 11 ++- .../SwiftAgentKitTests.swift | 66 +++++++++++++++++ 3 files changed, 135 insertions(+), 15 deletions(-) diff --git a/Sources/SwiftAgentKit/Context/ArtifactTools.swift b/Sources/SwiftAgentKit/Context/ArtifactTools.swift index 4f2e19a..139ac78 100644 --- a/Sources/SwiftAgentKit/Context/ArtifactTools.swift +++ b/Sources/SwiftAgentKit/Context/ArtifactTools.swift @@ -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) { @@ -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. diff --git a/Sources/SwiftAgentKit/Context/ContextManager.swift b/Sources/SwiftAgentKit/Context/ContextManager.swift index 08ef927..ae32ace 100644 --- a/Sources/SwiftAgentKit/Context/ContextManager.swift +++ b/Sources/SwiftAgentKit/Context/ContextManager.swift @@ -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? { diff --git a/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift b/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift index d82c2ee..4593473 100644 --- a/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift +++ b/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift @@ -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) +}