From f0a4cd6e92cd2b30c002da904778a5996057aed1 Mon Sep 17 00:00:00 2001 From: Ayman Hamed Date: Mon, 24 Aug 2026 20:43:29 +0300 Subject: [PATCH] Eager artifact persistence: capture every persist-worthy output, not just spills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Short runs that never exceed the inline budget stored NOTHING — after a restart, artifact_list honestly returned empty (observed live). The agent loop now hands each turn's completed results to the context manager, which saves persist-worthy ones immediately (≥1k chars, non-error, non-retrieval), deduped with the spill paths per tool-call id; the receipt path reuses the cached id instead of double-storing. Also: artifact_list joins retrievalToolNames so its output is never re-spilled as a nested artifact (observed live). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Context/ContextManager.swift | 37 ++++++++++++-- Sources/SwiftAgentKit/Core/Agent.swift | 6 +++ .../SwiftAgentKitTests.swift | 48 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftAgentKit/Context/ContextManager.swift b/Sources/SwiftAgentKit/Context/ContextManager.swift index f26cc8b..5f7cd03 100644 --- a/Sources/SwiftAgentKit/Context/ContextManager.swift +++ b/Sources/SwiftAgentKit/Context/ContextManager.swift @@ -39,7 +39,7 @@ public final class ContextManager: @unchecked Sendable { /// Tools whose output IS the retrieval mechanism — never re-truncate or /// re-spill their results, or the model loops calling them to "get the full /// output" that keeps getting bounded. - private static let retrievalToolNames: Set = ["artifact_read", "artifact_search"] + private static let retrievalToolNames: Set = ["artifact_read", "artifact_search", "artifact_list"] /// Keep the whole conversation inline (no externalization) while its total /// size is under this many characters. ContextSift only earns its keep when @@ -100,6 +100,30 @@ public final class ContextManager: @unchecked Sendable { return tools } + /// Minimum characters for eager persistence — "exit 0"-style outputs + /// aren't worth a disk write. + public var eagerPersistMinChars = 1_000 + + /// Save completed tool results to the store AS THEY FINISH, not only when + /// sifting later spills them. Without this, a short run that never exceeds + /// the inline budget stores nothing — and a restart-surviving store + /// (FileArtifactStore) has no history to serve. Deduped with the spill + /// paths per tool-call id; errors, retrieval tools, and tiny outputs skip. + /// The store's own persistFilter still decides what reaches disk. + public func recordCompletedResults(_ results: [AgentToolResult]) async { + for result in results { + let name = result.toolName ?? "tool" + guard !result.isError, + !Self.retrievalToolNames.contains(name), + result.result.count >= eagerPersistMinChars, + cachedActiveArtifact(result.toolCallId) == nil + else { continue } + let artifact = await store.save(result.result, description: "\(name) output", + toolCallID: result.toolCallId, toolName: name) + cacheActiveArtifact(result.toolCallId, artifact.id) + } + } + // MARK: - Build /// Build the model-facing messages: identity/system + receipt ledger, then @@ -267,8 +291,15 @@ 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, toolName: name) - artifactIDs = [artifact.id] + // Reuse an artifact already saved for this call (eager persistence + // or an active-display spill) instead of storing a duplicate. + if let existing = cachedActiveArtifact(result.toolCallId) { + artifactIDs = [existing] + } else { + let artifact = await store.save(result.result, description: "\(name) output", toolCallID: result.toolCallId, toolName: name) + cacheActiveArtifact(result.toolCallId, artifact.id) + artifactIDs = [artifact.id] + } } let receipt = ToolReceipt( callID: result.toolCallId, diff --git a/Sources/SwiftAgentKit/Core/Agent.swift b/Sources/SwiftAgentKit/Core/Agent.swift index 1853994..061a466 100644 --- a/Sources/SwiftAgentKit/Core/Agent.swift +++ b/Sources/SwiftAgentKit/Core/Agent.swift @@ -825,6 +825,9 @@ public actor Agent { onTurnCompleted?(intercepted.text, true) let turnActions = ToolActions() let results = await dispatchToolCalls(toolCalls, turn: totalTurns, query: query, actions: turnActions) + // Eager persistence: restart-surviving stores capture every + // persist-worthy output, not just what sifting happens to spill. + if let cm = config.contextManager { await cm.recordCompletedResults(results) } toolsExecuted += results.count toolErrors += results.filter(\.isError).count lastTurnErrors = repairableErrors(from: results, actions: turnActions) @@ -1048,6 +1051,9 @@ public actor Agent { // `config.parallelToolCalls` opts in) let turnActions = ToolActions() let results = await dispatchToolCalls(toolCalls, turn: totalTurns, query: query, actions: turnActions) + // Eager persistence: restart-surviving stores capture every + // persist-worthy output, not just what sifting happens to spill. + if let cm = config.contextManager { await cm.recordCompletedResults(results) } toolsExecuted += results.count toolErrors += results.filter(\.isError).count lastTurnErrors = repairableErrors(from: results, actions: turnActions) diff --git a/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift b/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift index 0a78130..e2f2b63 100644 --- a/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift +++ b/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift @@ -2656,3 +2656,51 @@ private struct NoopTool: AgentTool { // Transient: the note never lands in the persistent conversation. #expect(!agent.conversation.allMessages().contains { $0.content.contains("[Progress check]") }) } + +// MARK: - Eager artifact persistence + +@Test func testRecordCompletedResultsPersistsWithoutSpill() async { + // A SHORT conversation (under inline budget → sifting never spills) must + // still land persist-worthy outputs in a restart-surviving store. + let dir = tempArtifactDir() + defer { try? FileManager.default.removeItem(at: dir) } + let store = FileArtifactStore(directory: dir) + let manager = ContextManager(store: store) // default budgets, nothing spills + let log = String(repeating: "build line\n", count: 200) // ~2.2k chars + + await manager.recordCompletedResults([ + .success(toolCallId: "c1", toolName: "run_shell", result: log), + .success(toolCallId: "c2", toolName: "run_shell", result: "exit 0"), // tiny → skip + .error(toolCallId: "c3", toolName: "run_shell", message: String(repeating: "e", count: 2_000)), // error → skip + .success(toolCallId: "c4", toolName: "artifact_list", result: String(repeating: "a", count: 2_000)), // retrieval → skip + ]) + + let restarted = FileArtifactStore(directory: dir) + let listed = await restarted.list(limit: 10) + #expect(listed.count == 1) + #expect(listed.first?.toolName == "run_shell") +} + +@Test func testEagerPersistDedupesWithReceiptSpill() async { + // Eager save first, then the receipt path for the SAME call — one artifact. + let dir = tempArtifactDir() + defer { try? FileManager.default.removeItem(at: dir) } + let store = FileArtifactStore(directory: dir) + let manager = ContextManager(store: store, summaryLength: 40, inlineBudgetChars: 0) + let log = String(repeating: "x", count: 2_000) + let result = AgentToolResult.success(toolCallId: "c1", toolName: "run_shell", result: log) + + await manager.recordCompletedResults([result]) + // Externalize the exchange → receipt path runs for the same tool call. + let messages: [AgentMessage] = [ + .user("build"), + .assistant(content: "", toolCalls: [AgentToolCall(id: "c1", name: "run_shell")]), + .tool(results: [result]), + .assistant("done"), + .user("next"), + ] + _ = await manager.modelMessages(messages) { $0 } + + let listed = await store.list(limit: 10) + #expect(listed.count == 1) // no duplicate for the same call +}