From d09f60994196532cad215ddad375334b4cc467f8 Mon Sep 17 00:00:00 2001 From: Ayman Hamed Date: Mon, 24 Aug 2026 12:27:05 +0300 Subject: [PATCH] Persistent artifacts: FileArtifactStore, artifact_list, tool-aware saves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-tier store: memory keeps everything for the session (unchanged behavior); a disk tier under a caller-chosen directory persists the subset approved by persistFilter(toolName) with an oldest-first byte budget, so tool history survives restarts — retrieved strictly on demand. ArtifactStore.save now requires the tool-aware signature (legacy 3-arg lives in a protocol extension forwarding dynamically); ContextManager passes the producing tool's name. artifact_list (new) enumerates both tiers for discovery and auto-registers when the store is listable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SwiftAgentKit/Context/ArtifactStore.swift | 58 +++++- .../SwiftAgentKit/Context/ArtifactTools.swift | 45 +++++ .../Context/ContextManager.swift | 11 +- .../Context/FileArtifactStore.swift | 167 ++++++++++++++++++ .../SwiftAgentKitTests.swift | 90 ++++++++++ 5 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 Sources/SwiftAgentKit/Context/FileArtifactStore.swift diff --git a/Sources/SwiftAgentKit/Context/ArtifactStore.swift b/Sources/SwiftAgentKit/Context/ArtifactStore.swift index 91829ad..d63f57b 100644 --- a/Sources/SwiftAgentKit/Context/ArtifactStore.swift +++ b/Sources/SwiftAgentKit/Context/ArtifactStore.swift @@ -14,14 +14,19 @@ import Foundation public struct Artifact: Sendable, Identifiable, Equatable { public let id: String public let toolCallID: String? + /// Name of the tool that produced this output (nil for legacy saves). + /// Drives persistence filters (e.g. keep run_shell, skip read_file). + public let toolName: String? public let description: String public let content: String public let byteCount: Int public let createdAt: Date - public init(id: String, toolCallID: String?, description: String, content: String, createdAt: Date = Date()) { + public init(id: String, toolCallID: String?, toolName: String? = nil, + description: String, content: String, createdAt: Date = Date()) { self.id = id self.toolCallID = toolCallID + self.toolName = toolName self.description = description self.content = content self.byteCount = content.utf8.count @@ -29,6 +34,23 @@ public struct Artifact: Sendable, Identifiable, Equatable { } } +/// Lightweight listing row for `artifact_list` — no content payload. +public struct ArtifactSummary: Sendable, Equatable { + public let id: String + public let toolName: String? + public let description: String + public let byteCount: Int + public let createdAt: Date + + public init(id: String, toolName: String?, description: String, byteCount: Int, createdAt: Date) { + self.id = id + self.toolName = toolName + self.description = description + self.byteCount = byteCount + self.createdAt = createdAt + } +} + /// A bounded slice of an artifact returned by `read`. public struct ArtifactSlice: Sendable, Equatable { public let artifactID: String @@ -64,7 +86,9 @@ public struct ArtifactMatch: Sendable, Equatable { /// conformers must be reference types (classes or actors). public protocol ArtifactStore: AnyObject, Sendable { /// Store a full output and return its artifact record (with a fresh id). - func save(_ content: String, description: String, toolCallID: String?) async -> Artifact + /// `toolName` is the producing tool (nil when unknown) — persistence + /// filters key off it (e.g. keep run_shell logs, skip re-derivable reads). + func save(_ content: String, description: String, toolCallID: String?, toolName: String?) async -> Artifact /// Fetch a stored artifact by id. func get(_ id: String) async -> Artifact? /// Read a bounded character range of an artifact. @@ -73,20 +97,44 @@ public protocol ArtifactStore: AnyObject, Sendable { func search(_ id: String, query: String, maxMatches: Int) async -> [ArtifactMatch] } +public extension ArtifactStore { + /// Legacy convenience — forwards to the tool-aware requirement (dynamic + /// dispatch, so conformers' filters always see the call). + func save(_ content: String, description: String, toolCallID: String?) async -> Artifact { + await save(content, description: description, toolCallID: toolCallID, toolName: nil) + } +} + +/// Stores that can enumerate their contents — powers the `artifact_list` +/// tool (discovery of prior-session outputs without preloading anything). +public protocol ListableArtifactStore: ArtifactStore { + /// Newest-first summaries, bounded by `limit`. + func list(limit: Int) async -> [ArtifactSummary] +} + /// In-memory artifact store (process lifetime). Good for apps and tests; swap in /// a file-backed store for durability. -public actor InMemoryArtifactStore: ArtifactStore { +public actor InMemoryArtifactStore: ArtifactStore, ListableArtifactStore { private var artifacts: [String: Artifact] = [:] public init() {} - public func save(_ content: String, description: String, toolCallID: String?) -> Artifact { + public func save(_ content: String, description: String, toolCallID: String?, toolName: String?) -> Artifact { let id = "artifact-" + UUID().uuidString.lowercased().replacingOccurrences(of: "-", with: "").prefix(12) - let artifact = Artifact(id: String(id), toolCallID: toolCallID, description: description, content: content) + let artifact = Artifact(id: String(id), toolCallID: toolCallID, toolName: toolName, + description: description, content: content) artifacts[artifact.id] = artifact return artifact } + public func list(limit: Int) -> [ArtifactSummary] { + artifacts.values + .sorted { $0.createdAt > $1.createdAt } + .prefix(max(0, limit)) + .map { ArtifactSummary(id: $0.id, toolName: $0.toolName, description: $0.description, + byteCount: $0.byteCount, createdAt: $0.createdAt) } + } + public func get(_ id: String) -> Artifact? { artifacts[id] } diff --git a/Sources/SwiftAgentKit/Context/ArtifactTools.swift b/Sources/SwiftAgentKit/Context/ArtifactTools.swift index 139ac78..6165e08 100644 --- a/Sources/SwiftAgentKit/Context/ArtifactTools.swift +++ b/Sources/SwiftAgentKit/Context/ArtifactTools.swift @@ -138,6 +138,51 @@ public struct ArtifactSearchTool: AgentTool { } } +/// List stored tool outputs (including prior sessions, when the store is +/// file-backed) so the model can discover retrievable history on demand. +public struct ArtifactListTool: AgentTool { + public let name = "artifact_list" + public let description = """ + List stored outputs of previous tool calls in this conversation — \ + including ones from earlier sessions. Returns id, tool, description, age \ + and size; use artifact_read or artifact_search with an id to retrieve one. + """ + public let parameters = ToolParameters( + properties: [ + "limit": ToolParameterProperty(type: "integer", description: "Maximum entries, newest first (default 25)"), + ], + required: [] + ) + + private let store: any ListableArtifactStore + + public init(store: any ListableArtifactStore) { + self.store = store + } + + public func execute(parameters: [String: Any]) async throws -> AgentToolResult { + let limit = intValue(parameters["limit"]) ?? 25 + let summaries = await store.list(limit: limit) + guard !summaries.isEmpty else { + return .success(toolCallId: "", toolName: name, result: "No stored outputs for this conversation.") + } + let now = Date() + let lines = summaries.map { summary in + let age = Self.ageLabel(from: summary.createdAt, to: now) + let tool = summary.toolName ?? "tool" + return "\(summary.id) — \(tool) — \(summary.description) — \(age) — \(summary.byteCount) bytes" + } + return .success(toolCallId: "", toolName: name, result: lines.joined(separator: "\n")) + } + + static func ageLabel(from created: Date, to now: Date) -> String { + let seconds = max(0, now.timeIntervalSince(created)) + if seconds < 3600 { return "\(Int(seconds / 60))m ago" } + if seconds < 86_400 { return "\(Int(seconds / 3600))h ago" } + return "\(Int(seconds / 86_400))d ago" + } +} + // Tool arguments arrive as Int or Double depending on JSON decoding. private func intValue(_ value: Any?) -> Int? { if let i = value as? Int { return i } diff --git a/Sources/SwiftAgentKit/Context/ContextManager.swift b/Sources/SwiftAgentKit/Context/ContextManager.swift index ae32ace..f26cc8b 100644 --- a/Sources/SwiftAgentKit/Context/ContextManager.swift +++ b/Sources/SwiftAgentKit/Context/ContextManager.swift @@ -91,8 +91,13 @@ public final class ContextManager: @unchecked Sendable { /// The retrieval tools the model uses to pull full outputs back from the /// store. Auto-registered by the agent when a context manager is set. + /// `artifact_list` joins them when the store can enumerate its contents. public var artifactTools: [any AgentTool] { - [ArtifactReadTool(store: store), ArtifactSearchTool(store: store)] + var tools: [any AgentTool] = [ArtifactReadTool(store: store), ArtifactSearchTool(store: store)] + if let listable = store as? any ListableArtifactStore { + tools.append(ArtifactListTool(store: listable)) + } + return tools } // MARK: - Build @@ -262,7 +267,7 @@ public final class ContextManager: @unchecked Sendable { // Don't spill retrieval-tool output to a new artifact — that would nest // artifacts of artifacts and never surface the real content. if result.result.count > summaryLength && !Self.retrievalToolNames.contains(name) { - let artifact = await store.save(result.result, description: "\(name) output", toolCallID: result.toolCallId) + let artifact = await store.save(result.result, description: "\(name) output", toolCallID: result.toolCallId, toolName: name) artifactIDs = [artifact.id] } let receipt = ToolReceipt( @@ -351,7 +356,7 @@ public final class ContextManager: @unchecked Sendable { if let cached = cachedActiveArtifact(result.toolCallId) { artifactID = cached } else { - let artifact = await store.save(result.result, description: "\(name) output", toolCallID: result.toolCallId) + let artifact = await store.save(result.result, description: "\(name) output", toolCallID: result.toolCallId, toolName: name) cacheActiveArtifact(result.toolCallId, artifact.id) artifactID = artifact.id } diff --git a/Sources/SwiftAgentKit/Context/FileArtifactStore.swift b/Sources/SwiftAgentKit/Context/FileArtifactStore.swift new file mode 100644 index 0000000..b9ae154 --- /dev/null +++ b/Sources/SwiftAgentKit/Context/FileArtifactStore.swift @@ -0,0 +1,167 @@ +// +// FileArtifactStore.swift +// SwiftAgentKit +// +// Two-tier artifact store: an in-memory tier holds EVERYTHING for the running +// session (same behavior as InMemoryArtifactStore), while a disk tier persists +// the subset approved by `persistFilter` so tool history survives restarts — +// retrieved strictly on demand via artifact_list/artifact_read. +// + +import Foundation + +public actor FileArtifactStore: ArtifactStore, ListableArtifactStore { + + /// Persisted sidecar metadata (`.json`); content lives in `.txt`. + private struct Meta: Codable { + let id: String + let toolCallID: String? + let toolName: String? + let description: String + let byteCount: Int + let createdAt: Date + } + + private let directory: URL + private let persistFilter: @Sendable (String?) -> Bool + private let maxBytes: Int + + /// Session tier: every artifact saved this session (also acts as a read + /// cache for disk artifacts already touched). + private var memory: [String: Artifact] = [:] + /// Disk tier index, loaded once at init; content is lazy-loaded on demand. + private var diskIndex: [String: Meta] = [:] + + /// - Parameters: + /// - directory: per-scope folder (e.g. per conversation); created if needed. + /// - persistFilter: given the producing tool's name (nil for unknown), + /// decides whether the artifact is written to disk. Default: persist all. + /// - maxBytes: disk budget for this directory; oldest artifacts are + /// evicted first when exceeded. + public init(directory: URL, + persistFilter: @escaping @Sendable (String?) -> Bool = { _ in true }, + maxBytes: Int = 25_000_000) { + self.directory = directory + self.persistFilter = persistFilter + self.maxBytes = maxBytes + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + // Load the metadata index (cheap: sidecars only, no content). + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let sidecars = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil))? + .filter { $0.pathExtension == "json" } ?? [] + for url in sidecars { + if let data = try? Data(contentsOf: url), + let meta = try? decoder.decode(Meta.self, from: data) { + diskIndex[meta.id] = meta + } + } + } + + // MARK: - ArtifactStore + + public func save(_ content: String, description: String, toolCallID: String?, toolName: String?) -> Artifact { + let id = "artifact-" + UUID().uuidString.lowercased().replacingOccurrences(of: "-", with: "").prefix(12) + let artifact = Artifact(id: String(id), toolCallID: toolCallID, toolName: toolName, + description: description, content: content) + memory[artifact.id] = artifact + if persistFilter(toolName) { + persist(artifact) + enforceBudget() + } + return artifact + } + + public func get(_ id: String) -> Artifact? { + if let cached = memory[id] { return cached } + guard let meta = diskIndex[id], let loaded = loadContent(id: id, meta: meta) else { return nil } + memory[id] = loaded // cache so repeated reads skip disk + return loaded + } + + public func read(_ id: String, offset: Int, limit: Int) -> ArtifactSlice? { + guard let artifact = get(id) else { return nil } + let chars = Array(artifact.content) + let total = chars.count + let start = max(0, min(offset, total)) + let end = max(start, min(start + max(0, limit), total)) + return ArtifactSlice(artifactID: id, offset: start, content: String(chars[start.. [ArtifactMatch] { + guard let artifact = get(id), !query.isEmpty else { return [] } + let needle = query.lowercased() + var matches: [ArtifactMatch] = [] + for (index, line) in artifact.content.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { + if line.lowercased().contains(needle) { + matches.append(ArtifactMatch(line: index + 1, text: String(line.prefix(500)))) + if matches.count >= maxMatches { break } + } + } + return matches + } + + // MARK: - ListableArtifactStore + + public func list(limit: Int) -> [ArtifactSummary] { + // Merge both tiers (memory wins on id collision), newest first. + var byID: [String: ArtifactSummary] = [:] + for meta in diskIndex.values { + byID[meta.id] = ArtifactSummary(id: meta.id, toolName: meta.toolName, + description: meta.description, + byteCount: meta.byteCount, createdAt: meta.createdAt) + } + for artifact in memory.values { + byID[artifact.id] = ArtifactSummary(id: artifact.id, toolName: artifact.toolName, + description: artifact.description, + byteCount: artifact.byteCount, createdAt: artifact.createdAt) + } + return byID.values.sorted { $0.createdAt > $1.createdAt }.prefix(max(0, limit)).map { $0 } + } + + // MARK: - Disk tier + + private func contentURL(_ id: String) -> URL { directory.appendingPathComponent(id + ".txt") } + private func metaURL(_ id: String) -> URL { directory.appendingPathComponent(id + ".json") } + + private func persist(_ artifact: Artifact) { + let meta = Meta(id: artifact.id, toolCallID: artifact.toolCallID, toolName: artifact.toolName, + description: artifact.description, byteCount: artifact.byteCount, + createdAt: artifact.createdAt) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + // Content FIRST, sidecar last — an index entry must never point at a + // missing file. Failures leave the artifact memory-only (session intact). + do { + try artifact.content.write(to: contentURL(artifact.id), atomically: true, encoding: .utf8) + try encoder.encode(meta).write(to: metaURL(artifact.id), options: .atomic) + diskIndex[artifact.id] = meta + } catch { + try? FileManager.default.removeItem(at: contentURL(artifact.id)) + } + } + + private func loadContent(id: String, meta: Meta) -> Artifact? { + guard let content = try? String(contentsOf: contentURL(id), encoding: .utf8) else { + // Corrupt/missing content → drop the dangling index entry. + diskIndex[id] = nil + try? FileManager.default.removeItem(at: metaURL(id)) + return nil + } + return Artifact(id: id, toolCallID: meta.toolCallID, toolName: meta.toolName, + description: meta.description, content: content, createdAt: meta.createdAt) + } + + private func enforceBudget() { + var total = diskIndex.values.reduce(0) { $0 + $1.byteCount } + guard total > maxBytes else { return } + for meta in diskIndex.values.sorted(by: { $0.createdAt < $1.createdAt }) { + try? FileManager.default.removeItem(at: contentURL(meta.id)) + try? FileManager.default.removeItem(at: metaURL(meta.id)) + diskIndex[meta.id] = nil + total -= meta.byteCount + if total <= maxBytes { break } + } + } +} diff --git a/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift b/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift index 4593473..043f7bc 100644 --- a/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift +++ b/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift @@ -2486,3 +2486,93 @@ func liveAgentRecallsToolConclusionAfterCompaction() async throws { #expect(result.isError) } + +// MARK: - Persistent artifacts (FileArtifactStore + artifact_list) + +private func tempArtifactDir() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("sak-artifacts-\(UUID().uuidString)") +} + +@Test func testFileArtifactStoreSurvivesRestart() async { + let dir = tempArtifactDir() + defer { try? FileManager.default.removeItem(at: dir) } + let first = FileArtifactStore(directory: dir) + let saved = await first.save("BUILD FAILED\nexit 65", description: "run_shell output", + toolCallID: "c1", toolName: "run_shell") + + // A NEW store on the same directory (= app relaunch) serves the artifact. + let second = FileArtifactStore(directory: dir) + let loaded = await second.get(saved.id) + #expect(loaded?.content == "BUILD FAILED\nexit 65") + #expect(loaded?.toolName == "run_shell") + let matches = await second.search(saved.id, query: "exit 65", maxMatches: 5) + #expect(matches.count == 1) +} + +@Test func testFileArtifactStoreFilterKeepsSessionTierOnly() async { + let dir = tempArtifactDir() + defer { try? FileManager.default.removeItem(at: dir) } + // Re-derivable reads: retrievable in-session, NOT written to disk. + let store = FileArtifactStore(directory: dir, persistFilter: { $0 != "read_file" }) + let read = await store.save("file contents", description: "read_file output", + toolCallID: "c1", toolName: "read_file") + let inSession = await store.get(read.id) + #expect(inSession?.content == "file contents") + + let restarted = FileArtifactStore(directory: dir) + let afterRestart = await restarted.get(read.id) + #expect(afterRestart == nil) +} + +@Test func testFileArtifactStoreEvictsOldestOverBudget() async { + let dir = tempArtifactDir() + defer { try? FileManager.default.removeItem(at: dir) } + let store = FileArtifactStore(directory: dir, maxBytes: 250) + let a = await store.save(String(repeating: "a", count: 200), description: "run_shell output", + toolCallID: nil, toolName: "run_shell") + let b = await store.save(String(repeating: "b", count: 200), description: "run_shell output", + toolCallID: nil, toolName: "run_shell") + // Oldest (a) evicted from DISK; newest (b) kept. + let restarted = FileArtifactStore(directory: dir) + #expect(await restarted.get(a.id) == nil) + #expect(await restarted.get(b.id) != nil) +} + +@Test func testArtifactListToolListsBothTiers() async throws { + let dir = tempArtifactDir() + defer { try? FileManager.default.removeItem(at: dir) } + let first = FileArtifactStore(directory: dir) + let old = await first.save("old log", description: "run_shell output", + toolCallID: nil, toolName: "run_shell") + + let second = FileArtifactStore(directory: dir) // relaunch + let fresh = await second.save("fresh", description: "web_search output", + toolCallID: nil, toolName: "web_search") + let tool = ArtifactListTool(store: second) + let result = try await tool.execute(parameters: [:]) + #expect(result.result.contains(old.id)) // prior session, from disk index + #expect(result.result.contains(fresh.id)) // current session + #expect(result.result.contains("run_shell")) + + let empty = ArtifactListTool(store: FileArtifactStore(directory: tempArtifactDir())) + let none = try await empty.execute(parameters: [:]) + #expect(none.result.contains("No stored outputs")) +} + +@Test func testContextManagerSpillsWithToolName() async { + // The manager passes the producing tool's name so persistence filters work. + let dir = tempArtifactDir() + defer { try? FileManager.default.removeItem(at: dir) } + let store = FileArtifactStore(directory: dir) + let manager = ContextManager(store: store, maxActiveResultChars: 40, inlineBudgetChars: 0) + let messages: [AgentMessage] = [ + .user("build it"), + .assistant(content: "", toolCalls: [AgentToolCall(id: "c1", name: "run_shell")]), + .tool(results: [.success(toolCallId: "c1", toolName: "run_shell", + result: String(repeating: "x", count: 500))]), + ] + _ = await manager.modelMessages(messages) { $0 } + let listed = await store.list(limit: 5) + #expect(listed.first?.toolName == "run_shell") +}