Skip to content
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ let package = Package(
.library(name: "SwiftAgentKitMac", targets: ["SwiftAgentKitMac"]),
],
dependencies: [
.package(url: "https://github.com/ayman3000/LLMProviderKit.git", from: "0.1.0-alpha.41"),
.package(url: "https://github.com/ayman3000/LLMProviderKit.git", from: "0.1.0-alpha.42"),
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "603.0.0"),
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.1"),
.package(url: "https://github.com/swiftlang/swift-docc-plugin.git", from: "1.4.0"),
Expand Down
28 changes: 25 additions & 3 deletions Sources/SwiftAgentKit/Context/ArtifactStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ public struct ArtifactMatch: Sendable, Equatable {
/// A store is shared by reference between agents (parent and sub-agents), so
/// 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).
/// Store a full output and return its artifact record. Built-in stores
/// derive the id from the tool-call id and content (`Artifact.makeID`)
/// and return the existing artifact instead of storing it twice.
/// `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
Expand All @@ -112,6 +114,24 @@ public protocol ListableArtifactStore: ArtifactStore {
func list(limit: Int) async -> [ArtifactSummary]
}

public extension Artifact {
/// The id a store gives an artifact: `artifact-` + 12 lowercase hex.
///
/// With a tool-call id it is derived from that id AND the content, so the
/// same evicted output gets the same id after a relaunch (its receipt is
/// the same bytes, which keeps the prompt cache warm) and a store can
/// reuse the artifact instead of writing a duplicate. The content is part
/// of the key because some providers reuse call ids (`call_0`) across
/// runs: a different output under the same call id gets its own artifact.
/// Without a call id the id is random, as before.
static func makeID(toolCallID: String?, content: String) -> String {
guard let toolCallID, !toolCallID.isEmpty else {
return "artifact-" + UUID().uuidString.lowercased().replacingOccurrences(of: "-", with: "").prefix(12)
}
return "artifact-" + PromptDigest.hex(toolCallID + "\u{0}" + content).prefix(12)
}
}

/// In-memory artifact store (process lifetime). Good for apps and tests; swap in
/// a file-backed store for durability.
public actor InMemoryArtifactStore: ArtifactStore, ListableArtifactStore {
Expand All @@ -120,8 +140,10 @@ public actor InMemoryArtifactStore: ArtifactStore, ListableArtifactStore {
public init() {}

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,
let id = Artifact.makeID(toolCallID: toolCallID, content: content)
// Same call, same output: already stored — reuse it.
if let existing = artifacts[id] { return existing }
let artifact = Artifact(id: id, toolCallID: toolCallID, toolName: toolName,
description: description, content: content)
artifacts[artifact.id] = artifact
return artifact
Expand Down
7 changes: 5 additions & 2 deletions Sources/SwiftAgentKit/Context/FileArtifactStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,11 @@ public actor FileArtifactStore: ArtifactStore, ListableArtifactStore {
// 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,
let id = Artifact.makeID(toolCallID: toolCallID, content: content)
// Same call, same output — saved earlier this session or before a
// relaunch: reuse it instead of writing a duplicate to disk.
if let existing = get(id) { return existing }
let artifact = Artifact(id: id, toolCallID: toolCallID, toolName: toolName,
description: description, content: content)
memory[artifact.id] = artifact
if persistFilter(toolName) {
Expand Down
159 changes: 114 additions & 45 deletions Sources/SwiftAgentKit/Core/Agent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ public struct AgentConfig: Sendable {
/// Compact before the next call once the last prompt passes this share of
/// the window. nil = only on a context-length error.
public var compactAtFraction: Double?
/// Build the system prompt's dynamic parts — persistent memory, the app's
/// per-run context, the skill index — once, and reuse them on every
/// later run (`Agent.frozenRunContext`) until `invalidateRunContext()` or
/// a compaction. Off (default): rebuilt at the start of every run, as
/// before. On, a conversation's system prompt stays the same bytes from
/// run to run, so provider prompt caches cover the whole conversation;
/// what the agent files during it reaches the model through the tool
/// result that filed it, not through the prompt.
public var freezeRunContext: Bool

public init(
provider: any LLMProvider,
Expand Down Expand Up @@ -194,10 +203,12 @@ public struct AgentConfig: Sendable {
toolGroups: [DeferredToolGroup] = [],
maxReasoningSeconds: TimeInterval? = nil,
contextCompactor: (any ContextCompactor)? = nil,
compactAtFraction: Double? = nil
compactAtFraction: Double? = nil,
freezeRunContext: Bool = false
) {
self.contextCompactor = contextCompactor
self.compactAtFraction = compactAtFraction
self.freezeRunContext = freezeRunContext
self.provider = provider
self.model = model
self.temperature = temperature
Expand Down Expand Up @@ -395,6 +406,15 @@ public actor Agent {

/// Set at the start of a run; the next request built records its digest.
private var firstRequestPending = false
/// The frozen parts of the system prompt (`AgentConfig.freezeRunContext`):
/// nil until a run builds them, and after `invalidateRunContext()` or a
/// compaction.
public private(set) var frozenRunContext: FrozenRunContext?
/// Bumped by `invalidateRunContext()`, `restoreRunContext` and
/// compaction. `runContextParts` captures this before its first await;
/// if it changed by the time the build finishes, the build was
/// overtaken by one of those and must not freeze its (now stale) result.
private var runContextGeneration = 0
/// The overflow safety net's cut: the id of the last stored message calls
/// leave out (with every older evictable step). Sticky until the request
/// passes the bound again; cleared by compaction, or when that message is
Expand Down Expand Up @@ -600,6 +620,60 @@ public actor Agent {
+ lines.joined(separator: "\n")
}

/// The tool line of the system prompt (empty without tools). Deferred
/// tools are left out, loaded or not.
private func toolInstructionLine(_ registered: [any AgentTool]) -> String {
guard !registered.isEmpty else { return "" }
let hiddenNames = Set(deferredGroups.flatMap(\.toolNames))
let toolNames = registered.map { $0.name }.filter { !hiddenNames.contains($0) }.joined(separator: ", ")
return """

You have access to the following tools: \(toolNames).
IMPORTANT: When the user's request requires action (reading files, running commands, searching, creating, etc.), you MUST use the available tools instead of answering from memory. Call the appropriate tool to get real information, then use the tool results to formulate your answer. Do not guess or hallucinate results — always call the tool to get the actual data.
"""
}

/// Memory, the per-run context and the skill index for this run: the
/// frozen ones when frozen for this base, otherwise read now (and frozen
/// when `freezeRunContext` is on).
private func runContextParts(base: String, toolLine: String, groupIndex: String,
toolNames: [String]) async -> FrozenRunContext {
let baseDigest = PromptDigest.hex([base, toolLine, groupIndex].joined(separator: "\u{1F}"))
if config.freezeRunContext, let frozen = frozenRunContext, frozen.baseDigest == baseDigest {
return frozen
}
let generation = runContextGeneration
var memory = ""
if let memoryStore {
memory = await memoryStore.loadContextBlock(project: memoryProject)
}
var runContext = ""
if let runContextProvider {
runContext = await runContextProvider(toolNames)
}
let parts = FrozenRunContext(baseDigest: baseDigest, memory: memory, runContext: runContext,
skillIndex: await skillRegistry.skillIndex())
// An invalidate/restore/compaction that landed during these awaits
// already bumped the generation; the stale parts above are still
// used for this run (the running reply keeps its prompt) but are
// not frozen, so the next run rebuilds instead of reusing them.
if config.freezeRunContext && generation == runContextGeneration { frozenRunContext = parts }
return parts
}

/// The system prompt, assembled exactly as before freezing existed: the
/// base, memory and the per-run context separated by a blank line, then
/// the tool line, the skill index and the tool-group index.
static func renderSystemPrompt(base: String, parts: FrozenRunContext,
toolLine: String, groupIndex: String) -> String {
var prompt = base
for block in [parts.memory, parts.runContext] where !block.isEmpty {
if !prompt.isEmpty { prompt += "\n\n" }
prompt += block
}
return prompt + toolLine + parts.skillIndex + groupIndex
}

/// The deferred group a tool belongs to, when it isn't loaded yet.
private func unloadedGroup(ofTool name: String) -> DeferredToolGroup? {
deferredGroups.first { $0.toolNames.contains(name) && !loadedToolGroupIDs.contains($0.id) }
Expand Down Expand Up @@ -644,6 +718,28 @@ public actor Agent {
try requireIdle()
runContextProvider = provider
}

/// Build memory, the per-run context and the skill index again at the
/// start of the next run. Safe during a run: the running reply keeps the
/// prompt it started with.
public func invalidateRunContext() {
frozenRunContext = nil
runContextGeneration += 1
}

/// Use a frozen context saved with the conversation (after a relaunch).
/// A no-op when `freezeRunContext` is off: nothing ever reads this
/// context without it, so storing one would just be a value no run
/// reads back and an app that persists `frozenRunContext` after each
/// run would keep saving a copy that is not in use. Idle-only, like the
/// other setters. When freeze is on, it is used only if it was made for
/// this engine's base; otherwise the next run builds fresh.
public func restoreRunContext(_ context: FrozenRunContext) throws {
try requireIdle()
guard config.freezeRunContext else { return }
frozenRunContext = context
runContextGeneration += 1
}
public func setSkillStore(_ store: (any AgentSkillStore)?) throws {
try requireIdle()
skillStore = store
Expand Down Expand Up @@ -997,51 +1093,20 @@ public actor Agent {
runTaskID = task.id
conversation.append(task)

// Get registered tools and strengthen system prompt (must happen before skill injection)
// Get registered tools, then the system prompt (before skill injection).
let registeredToolsEarly = await tools.allTools()
var effectiveSystemPrompt = config.systemPrompt ?? ""

// Persistent memory must be part of every model call. Loading it at run
// time ensures facts saved by earlier runs are immediately available.
if let memoryStore {
let memoryContext = await memoryStore.loadContextBlock(project: memoryProject)
if !memoryContext.isEmpty {
if !effectiveSystemPrompt.isEmpty {
effectiveSystemPrompt += "\n\n"
}
effectiveSystemPrompt += memoryContext
}
}

// The app's per-run context (built now, so what changed since the last
// run — a lesson filed after it — is used from this one).
if let runContextProvider {
let extra = await runContextProvider(registeredToolsEarly.map(\.name))
if !extra.isEmpty {
if !effectiveSystemPrompt.isEmpty { effectiveSystemPrompt += "\n\n" }
effectiveSystemPrompt += extra
}
}

if !registeredToolsEarly.isEmpty {
let hiddenNames = Set(deferredGroups.flatMap(\.toolNames))
let toolNames = registeredToolsEarly.map { $0.name }.filter { !hiddenNames.contains($0) }.joined(separator: ", ")
let toolInstruction = """

You have access to the following tools: \(toolNames).
IMPORTANT: When the user's request requires action (reading files, running commands, searching, creating, etc.), you MUST use the available tools instead of answering from memory. Call the appropriate tool to get real information, then use the tool results to formulate your answer. Do not guess or hallucinate results — always call the tool to get the actual data.
"""
effectiveSystemPrompt += toolInstruction
}

// Model-driven skills: a compact, query-independent index of every
// skill goes into the system prompt; the model loads full
// instructions on demand with `use_skill`. The index is byte-stable
// (alphabetical, changes only when skills change) so the prompt
// prefix stays cache-friendly across steps and turns.
let skillIndex = await skillRegistry.skillIndex() + toolGroupIndex()
if !skillIndex.isEmpty || !effectiveSystemPrompt.isEmpty {
conversation.setSystemMessage(.system(effectiveSystemPrompt + skillIndex))
let basePrompt = config.systemPrompt ?? ""
let toolLine = toolInstructionLine(registeredToolsEarly)
let groupIndex = toolGroupIndex()
// Persistent memory, the app's per-run context and the skill index:
// read now, so facts saved by earlier runs are available — or, with
// `freezeRunContext`, kept from the conversation's first run.
let parts = await runContextParts(base: basePrompt, toolLine: toolLine, groupIndex: groupIndex,
toolNames: registeredToolsEarly.map(\.name))
let systemPrompt = Self.renderSystemPrompt(base: basePrompt, parts: parts,
toolLine: toolLine, groupIndex: groupIndex)
if !systemPrompt.isEmpty {
conversation.setSystemMessage(.system(systemPrompt))
}

var totalTurns = 0
Expand Down Expand Up @@ -1790,6 +1855,10 @@ public actor Agent {
estimate: conversation.estimateTokens)
guard !middle.isEmpty, let checkpoint = await compactor.summarize(middle: middle, reason: reason) else { return nil }
conversation.replaceNonSystemMessages(ContextCompaction.assemble(checkpoint: checkpoint, tail: tail))
// Compaction is the sanctioned break: memory, the per-run context and
// the skill index are read again at the start of the next run.
frozenRunContext = nil
runContextGeneration += 1
overflowCutAfter = nil
let after = conversation.estimateTotalTokens(conversation.allMessages())
emit(.contextCompacted(tokensBefore: before, tokensAfter: after, reason: reason))
Expand Down
29 changes: 29 additions & 0 deletions Sources/SwiftAgentKit/Core/FrozenRunContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// FrozenRunContext.swift
// SwiftAgentKit
//
// The parts of the system prompt an agent re-reads at the start of a run —
// persistent memory, the app's per-run context, the skill index — kept as
// text, so a conversation can send the same system prompt run after run
// (provider prompt caching) and an app can save them with the conversation
// and restore them after a relaunch.
//

import Foundation

public struct FrozenRunContext: Codable, Sendable, Equatable {
/// `PromptDigest.hex` of what the engine builds itself: the configured
/// prompt, the tool line and the tool-group index. A frozen context is
/// used only with the same base, so a changed engine never sends a stale mix.
public let baseDigest: String
public let memory: String
public let runContext: String
public let skillIndex: String

public init(baseDigest: String, memory: String, runContext: String, skillIndex: String) {
self.baseDigest = baseDigest
self.memory = memory
self.runContext = runContext
self.skillIndex = skillIndex
}
}
3 changes: 3 additions & 0 deletions Sources/SwiftAgentKit/SubAgents/SubAgentSpawner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ public final class SubAgentSpawner: @unchecked Sendable {
config.subAgentModel = nil
config.subAgentReasoningEffort = nil
config.enableSubAgents = false // defense in depth vs. recursion
// A child's prompt is built fresh, from current memory, on every
// spawn; it runs one bounded task, so there is nothing to keep.
config.freezeRunContext = false
config.maxTurns = min(config.maxTurns, max(1, config.maxSubAgentTurns))
config.tools = [] // registered explicitly below

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@
"max_tokens" : 32000,
"messages" : [
{
"content" : "screenshot the page",
"content" : [
{
"cache_control" : {
"type" : "ephemeral"
},
"text" : "screenshot the page",
"type" : "text"
}
],
"role" : "user"
},
{
Expand Down
Loading
Loading