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
37 changes: 34 additions & 3 deletions Sources/SwiftAgentKit/Context/ContextManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = ["artifact_read", "artifact_search"]
private static let retrievalToolNames: Set<String> = ["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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions Sources/SwiftAgentKit/Core/Agent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
48 changes: 48 additions & 0 deletions Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading