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
58 changes: 53 additions & 5 deletions Sources/SwiftAgentKit/Context/ArtifactStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,43 @@ 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
self.createdAt = createdAt
}
}

/// 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
Expand Down Expand Up @@ -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.
Expand All @@ -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]
}
Expand Down
45 changes: 45 additions & 0 deletions Sources/SwiftAgentKit/Context/ArtifactTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
11 changes: 8 additions & 3 deletions Sources/SwiftAgentKit/Context/ContextManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
Expand Down
167 changes: 167 additions & 0 deletions Sources/SwiftAgentKit/Context/FileArtifactStore.swift
Original file line number Diff line number Diff line change
@@ -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 (`<id>.json`); content lives in `<id>.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..<end]),
hasMore: end < total, totalCharacters: total)
}

public func search(_ id: String, query: String, maxMatches: Int) -> [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 }
}
}
}
Loading
Loading