From 3161c914d1e5a40e012b6d2a62f17cc26d675965 Mon Sep 17 00:00:00 2001 From: Ayman Hamed Date: Fri, 9 Oct 2026 07:33:50 +0300 Subject: [PATCH 1/4] Require LLMProviderKit 0.1.0-alpha.39 (sorted bodies, whole-prompt Anthropic usage) Co-Authored-By: Claude Fable 5.1 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 8e7e705..d0b7d3b 100644 --- a/Package.swift +++ b/Package.swift @@ -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.35"), + .package(url: "https://github.com/ayman3000/LLMProviderKit.git", from: "0.1.0-alpha.39"), .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"), From c042b2439c6e8b17b5efb328cf1782dd0b8ae114 Mon Sep 17 00:00:00 2001 From: Ayman Hamed Date: Fri, 9 Oct 2026 07:35:48 +0300 Subject: [PATCH 2/4] Replayed tool-call arguments: sorted keys, numbers kept as numbers The arguments of every replayed call were re-encoded with an unsorted encoder on each model call, so the history could change bytes between calls and the prompt cache stopped there. AnyCodable also matched Bool before NSNumber: an integer 1 was replayed as true, 0 as false, 1.0 as true. Calls already stored keep what was written; new calls are right. Co-Authored-By: Claude Fable 5.1 --- Sources/SwiftAgentKit/Core/AgentMessage.swift | 29 ++++++++++--- .../CacheStableRequestTests.swift | 41 +++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 Tests/SwiftAgentKitTests/CacheStableRequestTests.swift diff --git a/Sources/SwiftAgentKit/Core/AgentMessage.swift b/Sources/SwiftAgentKit/Core/AgentMessage.swift index 1866d51..3b8426f 100644 --- a/Sources/SwiftAgentKit/Core/AgentMessage.swift +++ b/Sources/SwiftAgentKit/Core/AgentMessage.swift @@ -128,13 +128,10 @@ public struct AgentMessage: Identifiable, @unchecked Sendable, Codable { // Convert AgentToolCall to LLMToolCall so the provider // can serialize them for the model to correlate results let llmToolCalls = toolCalls.map { call in - // Serialize parameters to JSON string - let paramsData = try? JSONEncoder().encode(call.parameters) - let argsString = paramsData.flatMap { String(data: $0, encoding: .utf8) } ?? "{}" - return LLMToolCall( + LLMToolCall( id: call.id, name: call.name, - arguments: argsString, + arguments: Self.canonicalArguments(call.parameters), providerMetadata: call.providerMetadata ) } @@ -149,6 +146,17 @@ public struct AgentMessage: Identifiable, @unchecked Sendable, Codable { return .user(content) } } + + /// The JSON a replayed tool call carries: keys sorted at every level, so + /// the same call is the same bytes on every request. An unsorted encoder + /// reorders keys from one encode to the next, and the provider's prompt + /// cache stops at the first replayed call that differs. + static func canonicalArguments(_ parameters: [String: AnyCodable]) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(parameters) else { return "{}" } + return String(decoding: data, as: UTF8.self) + } } // MARK: - Message Role @@ -355,6 +363,17 @@ public struct AnyCodable: @unchecked Sendable, Codable { switch value { case is NSNull: try container.encodeNil() + case let number as NSNumber: + // JSONSerialization gives every number as NSNumber, and an NSNumber + // holding 0 or 1 also casts to Bool — so matching `Bool` first + // replayed an integer 1 as `true`. Ask the number what it is. + if CFGetTypeID(number) == CFBooleanGetTypeID() { + try container.encode(number.boolValue) + } else if CFNumberIsFloatType(number as CFNumber) { + try container.encode(number.doubleValue) + } else { + try container.encode(number.int64Value) + } case let bool as Bool: try container.encode(bool) case let int as Int: diff --git a/Tests/SwiftAgentKitTests/CacheStableRequestTests.swift b/Tests/SwiftAgentKitTests/CacheStableRequestTests.swift new file mode 100644 index 0000000..bfcac11 --- /dev/null +++ b/Tests/SwiftAgentKitTests/CacheStableRequestTests.swift @@ -0,0 +1,41 @@ +import Testing +import Foundation +import LLMProviderKit +import LLMProviderKitOpenAI +@testable import SwiftAgentKit + +/// Prompt caching reuses the longest identical start of a request. These pin +/// that SwiftAgentKit hands a provider the same bytes for the same history on +/// every call. +struct CacheStableRequestTests { + /// A call as a provider returns it: arguments parsed by JSONSerialization, + /// so every number is an NSNumber. + static func parsedCall(_ json: String, id: String = "call_1", name: String = "edit_file") -> AgentToolCall { + let object = (try? JSONSerialization.jsonObject(with: Data(json.utf8))) as? [String: Any] ?? [:] + return AgentToolCall(id: id, name: name, parameters: object.mapValues { AnyCodable($0) }) + } + + @Test func aReplayedCallIsTheSameBytesEveryTime() { + let call = Self.parsedCall( + #"{"path":"/tmp/a.swift","old_text":"x","new_text":"y","replace_all":false,"options":{"b":1,"a":2,"c":[1,2]}}"#) + let message = AgentMessage.assistant(content: "", toolCalls: [call]) + var seen = Set() + var noise: [[Int]] = [] + for i in 0..<200 { + noise.append([Int](repeating: i, count: 1 + (i * 37) % 257)) + if noise.count > 16 { noise.removeFirst(8) } + seen.insert(message.toLLMMessage().toolCalls?.first?.arguments ?? "") + } + #expect(seen.count == 1) + #expect(seen.first + == #"{"new_text":"y","old_text":"x","options":{"a":2,"b":1,"c":[1,2]},"path":"\/tmp\/a.swift","replace_all":false}"#) + } + + /// JSONSerialization gives `1` as an NSNumber that also casts to Bool: + /// replayed, the call said `true` where the model wrote `1`. + @Test func numbersAreReplayedAsTheModelWroteThem() { + let call = Self.parsedCall(#"{"count":1,"zero":0,"flag":true,"off":false,"ratio":2.5,"whole":1.0}"#) + let arguments = AgentMessage.assistant(content: "", toolCalls: [call]).toLLMMessage().toolCalls?.first?.arguments + #expect(arguments == #"{"count":1,"flag":true,"off":false,"ratio":2.5,"whole":1,"zero":0}"#) + } +} From 175eace754bc8dd2dbbfe6783a77bbf347cc4daa Mon Sep 17 00:00:00 2001 From: Ayman Hamed Date: Fri, 9 Oct 2026 07:41:51 +0300 Subject: [PATCH 3/4] Tools in name order, registrations in call order, first-call system digest ToolRegistry listed tools in dictionary order, which differs per instance: every engine sent its tools in another order. Every piece of registration work (register, registerAll, skills, setSkillStore's load, tool context, autonomous mode, init's) now waits for the one queued before it, so an app's override of a framework tool always wins (the learn_skill race). The agent also exposes firstRequestSystemDigest (stable FNV-1a 64) and registeredTools(). A wire test pins that consecutive calls of one chat are append-only on the OpenAI body. Co-Authored-By: Claude Fable 5.1 --- Sources/SwiftAgentKit/Core/Agent.swift | 83 +++++-- Sources/SwiftAgentKit/Core/PromptDigest.swift | 27 +++ Sources/SwiftAgentKit/Tools/AgentTool.swift | 11 +- .../CacheStableRequestTests.swift | 217 ++++++++++++++++++ 4 files changed, 316 insertions(+), 22 deletions(-) create mode 100644 Sources/SwiftAgentKit/Core/PromptDigest.swift diff --git a/Sources/SwiftAgentKit/Core/Agent.swift b/Sources/SwiftAgentKit/Core/Agent.swift index ce390cf..591857d 100644 --- a/Sources/SwiftAgentKit/Core/Agent.swift +++ b/Sources/SwiftAgentKit/Core/Agent.swift @@ -373,6 +373,12 @@ public actor Agent { /// and every post-init access is actor-isolated. private nonisolated(unsafe) var pendingRegistrationTasks: [Task] = [] + /// The latest queued registration (never cleared): the next one waits + /// for it, so registrations land in call order even while + /// `awaitPendingRegistrations` is draining the list. Same safety as + /// `pendingRegistrationTasks`. + private nonisolated(unsafe) var registrationTail: Task? + /// Logger. public private(set) var logger: AgentLogger @@ -381,6 +387,15 @@ public actor Agent { /// the full stored history. Useful for a context-usage indicator. public private(set) var lastPromptTokens = 0 + /// `PromptDigest.hex` of the system text sent on the latest run's first + /// model call; nil before any. Lets an app tell whether a reply started + /// from the same system prompt as the reply before — a changed one is a + /// prompt-cache miss for the whole conversation. + public private(set) var firstRequestSystemDigest: String? + + /// Set at the start of a run; the next request built records its digest. + private var firstRequestPending = false + /// Actor-isolated run/cancel state — actor serialization is the guard now. private var isRunActive = false @@ -479,7 +494,7 @@ public actor Agent { // call isolated methods like `register(_:)`.) if !config.tools.isEmpty { for tool in config.tools { - pendingRegistrationTasks.append(Task { [tools] in await tools.register(tool) }) + trackRegistration { [tools] in await tools.register(tool) } } } @@ -487,19 +502,19 @@ public actor Agent { // can pull full tool outputs back from external storage on demand. if let contextManager = config.contextManager { for tool in contextManager.artifactTools { - pendingRegistrationTasks.append(Task { [tools] in await tools.register(tool) }) + trackRegistration { [tools] in await tools.register(tool) } } } // Apply autonomous mode (skips the confirmation gate) if configured. if config.autonomousMode { - pendingRegistrationTasks.append(Task { [dispatcher] in await dispatcher.setAutonomousMode(true) }) + trackRegistration { [dispatcher] in await dispatcher.setAutonomousMode(true) } } // Deferred tool groups: `load_tools` exists only when something is deferred. if config.toolGroups.contains(where: { !$0.alwaysLoaded }) { let loadTool = LoadToolsTool(handle: AgentHandle(self)) - pendingRegistrationTasks.append(Task { [tools] in await tools.register(loadTool) }) + trackRegistration { [tools] in await tools.register(loadTool) } } // Sub-agents: register the delegation tool. The spawner strips this @@ -511,7 +526,7 @@ public actor Agent { let delegateTool = DelegateTaskTool(spawner: spawner, emit: { [weak self] event in self?.emitEvent(event) }) - pendingRegistrationTasks.append(Task { [tools] in await tools.register(delegateTool) }) + trackRegistration { [tools] in await tools.register(delegateTool) } } } @@ -604,10 +619,10 @@ public actor Agent { // No stale memory tool may keep writing into an unreachable inbox // or a detached store. memoryInbox = nil - trackRegistrationTask(Task { [tools] in + trackRegistration { [tools] in await tools.unregister(named: "remember") await tools.unregister(named: "update_agent_profile") - }) + } return } let notes = inbox ?? MemoryInbox() @@ -627,11 +642,11 @@ public actor Agent { guard let store = store else { return } register(LearnSkillTool(store: store, registry: skillRegistry)) register(UseSkillTool(registry: skillRegistry, handle: AgentHandle(self))) - trackRegistrationTask(Task { [skillRegistry] in + trackRegistration { [skillRegistry] in if let skills = try? await store.loadAll() { await skillRegistry.registerAll(skills) } - }) + } } public func setCallbacks(_ newCallbacks: AgentCallbacks?) throws { try requireIdle(); callbacks = newCallbacks } public func setPlanner(_ newPlanner: (any AgentPlanner)?) throws { try requireIdle(); planner = newPlanner } @@ -644,37 +659,63 @@ public actor Agent { /// Enable/disable autonomous mode at runtime. When `true`, tools marked /// `requiresConfirmation` run without prompting `onToolConfirmation`. public func setAutonomousMode(_ enabled: Bool) { - trackRegistrationTask(Task { await dispatcher.setAutonomousMode(enabled) }) + trackRegistration { [dispatcher] in await dispatcher.setAutonomousMode(enabled) } } - /// Register a tool. + /// Register a tool. Registrations run in call order, so a later + /// registration of the same name always wins: an app replacing a + /// framework tool (Naseem's `learn_skill`, registered after + /// `setSkillStore`) gets the same tool — and the same tool block on the + /// wire — on every engine. An app that registers its own `learn_skill` + /// BEFORE `setSkillStore` loses to the framework's. public func register(_ tool: any AgentTool) { - trackRegistrationTask(Task { await tools.register(tool) }) + trackRegistration { [tools] in await tools.register(tool) } } - /// Register multiple tools. + /// Register multiple tools (in call order, like `register(_:)`). public func registerAll(_ toolsToRegister: [any AgentTool]) { - trackRegistrationTask(Task { await tools.registerAll(toolsToRegister) }) + trackRegistration { [tools] in await tools.registerAll(toolsToRegister) } + } + + /// Every registered tool, by name, once the registrations still in + /// flight have landed (`register` is fire-and-forget). + public func registeredTools() async -> [any AgentTool] { + await awaitPendingRegistrations() + return await tools.allTools() } /// Set context fields for tools (e.g. current directory, selected files). public func setToolContext(_ context: [String: Any]) { // Swift 6: [String: Any] is not Sendable — wrap in @unchecked Sendable box let box = ToolContextBox(values: context) - trackRegistrationTask(Task { await dispatcher.setContext(box.values) }) + trackRegistration { [dispatcher] in await dispatcher.setContext(box.values) } } /// Register a skill for progressive disclosure. public func registerSkill(_ skill: AgentSkill) { - trackRegistrationTask(Task { await skillRegistry.register(skill) }) + trackRegistration { [skillRegistry] in await skillRegistry.register(skill) } } /// Register multiple skills. public func registerSkills(_ skills: [AgentSkill]) { - trackRegistrationTask(Task { await skillRegistry.registerAll(skills) }) + trackRegistration { [skillRegistry] in await skillRegistry.registerAll(skills) } } - private func trackRegistrationTask(_ task: Task) { + /// Queue registration work behind everything queued before it (the + /// tail), so it all lands in call order, whichever setter queued the + /// earlier work. Independent Tasks left the winner of a same-name + /// registration to scheduling. + /// + /// `nonisolated` so `init` can use it after `self` has escaped (it cannot + /// call isolated methods there); every other caller is actor-isolated, + /// which is what keeps the `nonisolated(unsafe)` state safe. + private nonisolated func trackRegistration(_ work: @escaping @Sendable () async -> Void) { + let previous = registrationTail + let task = Task { + await previous?.value + await work() + } + registrationTail = task pendingRegistrationTasks.append(task) } @@ -921,6 +962,7 @@ public actor Agent { await awaitPendingRegistrations() resetCancellation() + firstRequestPending = true let startTime = Date() emit(.started(query: query)) @@ -1766,6 +1808,11 @@ public actor Agent { let promptChars = llmMessages.reduce(0) { $0 + $1.content.count } let estimate = Int((Double(promptChars) / 3.5).rounded()) + llmMessages.count * 4 lastPromptTokens = estimate + if firstRequestPending { + firstRequestPending = false + firstRequestSystemDigest = PromptDigest.hex( + llmMessages.filter { $0.role == .system }.map(\.content).joined(separator: "\n\n")) + } return LLMRequest( model: config.model ?? config.provider.configuration.defaultModel ?? "", diff --git a/Sources/SwiftAgentKit/Core/PromptDigest.swift b/Sources/SwiftAgentKit/Core/PromptDigest.swift new file mode 100644 index 0000000..a195c1d --- /dev/null +++ b/Sources/SwiftAgentKit/Core/PromptDigest.swift @@ -0,0 +1,27 @@ +// +// PromptDigest.swift +// SwiftAgentKit +// +// A short, stable fingerprint of prompt text. Swift's `hashValue` is seeded +// per process, so it cannot compare a prompt across a relaunch; FNV-1a 64 is +// unseeded and cheap. +// + +import Foundation + +public enum PromptDigest { + /// FNV-1a, 64-bit, over the UTF-8 bytes. + public static func fnv1a64(_ text: String) -> UInt64 { + var hash: UInt64 = 0xcbf29ce484222325 + for byte in text.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x100000001b3 + } + return hash + } + + /// The digest as 16 lowercase hex characters. + public static func hex(_ text: String) -> String { + String(format: "%016llx", fnv1a64(text)) + } +} diff --git a/Sources/SwiftAgentKit/Tools/AgentTool.swift b/Sources/SwiftAgentKit/Tools/AgentTool.swift index c6b8aef..86ea436 100644 --- a/Sources/SwiftAgentKit/Tools/AgentTool.swift +++ b/Sources/SwiftAgentKit/Tools/AgentTool.swift @@ -295,14 +295,17 @@ public actor ToolRegistry { tools[name] } - /// Get all registered tools. + /// All registered tools, by name. A dictionary's own order differs for + /// every instance, so two engines with the same tools sent them in + /// different orders: no shared tool block between chats, and a full + /// prompt-cache miss on every rebuild. public func allTools() -> [any AgentTool] { - Array(tools.values) + tools.values.sorted { $0.name < $1.name } } - /// Get all tool names. + /// All tool names, sorted. public func allToolNames() -> [String] { - Array(tools.keys) + tools.keys.sorted() } /// Check if a tool is registered. diff --git a/Tests/SwiftAgentKitTests/CacheStableRequestTests.swift b/Tests/SwiftAgentKitTests/CacheStableRequestTests.swift index bfcac11..a45ea0f 100644 --- a/Tests/SwiftAgentKitTests/CacheStableRequestTests.swift +++ b/Tests/SwiftAgentKitTests/CacheStableRequestTests.swift @@ -39,3 +39,220 @@ struct CacheStableRequestTests { #expect(arguments == #"{"count":1,"flag":true,"off":false,"ratio":2.5,"whole":1,"zero":0}"#) } } + +/// Answers from a script: each turn is the tool calls to return, or `[]` for +/// a plain "done". Records every request it was sent. +final class ScriptedProvider: LLMProvider, @unchecked Sendable { + static let name = "scripted" + let configuration = LLMProviderConfiguration( + name: name, baseURL: URL(string: "inprocess://scripted")!, defaultModel: "mock") + private let lock = NSLock() + private var turns: [[LLMToolCall]] + private var requests: [LLMRequest] = [] + + init(turns: [[LLMToolCall]]) { self.turns = turns } + + var captured: [LLMRequest] { lock.withLock { requests } } + + func complete(_ request: LLMRequest) async throws -> LLMResponse { + let calls: [LLMToolCall] = lock.withLock { + requests.append(request) + return turns.isEmpty ? [] : turns.removeFirst() + } + if calls.isEmpty { + return LLMResponse(text: "done", finishReason: .stop, request: request, providerName: Self.name) + } + return LLMResponse(text: "", finishReason: .toolCalls, toolCalls: calls, + request: request, providerName: Self.name) + } + + func stream(_ request: LLMRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + continuation.yield(.text("done")) + continuation.yield(.finish(reason: .stop, usage: nil)) + continuation.finish() + } + } +} + +private struct SortTestTool: AgentTool { + let name: String + var description: String { "The \(name) tool." } + let parameters = ToolParameters(properties: [:], required: []) + func execute(parameters: [String: Any]) async throws -> AgentToolResult { + .success(toolCallId: "", toolName: name, result: "ok") + } +} + +private struct NamedTool: AgentTool { + let name: String + let description: String + let parameters = ToolParameters(properties: [:], required: []) + func execute(parameters: [String: Any]) async throws -> AgentToolResult { + .success(toolCallId: "", toolName: name, result: description) + } +} + +private struct EditTool: AgentTool { + let name = "edit_file" + let description = "Edit a file." + let parameters = ToolParameters(properties: [ + "path": ToolParameterProperty(type: "string", description: "file"), + "old_text": ToolParameterProperty(type: "string", description: "old"), + "new_text": ToolParameterProperty(type: "string", description: "new"), + ], required: ["path"]) + func execute(parameters: [String: Any]) async throws -> AgentToolResult { + .success(toolCallId: "", toolName: name, result: "edited") + } +} + +private struct ReadTool: AgentTool { + let name = "read_file" + let description = "Read a file." + let parameters = ToolParameters(properties: [ + "path": ToolParameterProperty(type: "string", description: "file"), + "limit": ToolParameterProperty(type: "integer", description: "characters"), + ], required: ["path"]) + func execute(parameters: [String: Any]) async throws -> AgentToolResult { + .success(toolCallId: "", toolName: name, result: "file text") + } +} + +/// A skill store whose `loadAll` waits until the test opens it — a slow disk +/// read the test controls. +private final class GatedSkillStore: AgentSkillStore, @unchecked Sendable { + private let lock = NSLock() + private var isOpen = false + private var waiting: [CheckedContinuation] = [] + + func open() { + let resumed: [CheckedContinuation] = lock.withLock { + isOpen = true + defer { waiting = [] } + return waiting + } + resumed.forEach { $0.resume() } + } + + func save(_ skill: AgentSkill) async throws {} + func delete(name: String) async throws {} + func loadAll() async throws -> [AgentSkill] { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let now: Bool = lock.withLock { + if isOpen { return true } + waiting.append(continuation) + return false + } + if now { continuation.resume() } + } + return [] + } +} + +extension CacheStableRequestTests { + /// What the OpenAI wire would send for `request` (sorted keys since + /// LLMProviderKit 0.1.0-alpha.39). + static func wireBody(_ request: LLMRequest) throws -> Data { + let provider = OpenAIProvider(configuration: OpenAIProvider.openAI(apiKey: "k", model: "mock")) + return try #require(try provider.prepareRequest(request, stream: false).httpBody) + } + + /// The body's bytes up to (not including) the `]` that closes its messages + /// array — "messages" is the first key of a sorted OpenAI body. + static func messagesPrefix(_ body: Data) -> Data? { + let text = String(decoding: body, as: UTF8.self) + guard text.hasPrefix(#"{"messages":["#), let end = text.range(of: #"],"model":"#) else { return nil } + return Data(text[.. String? { + let text = String(decoding: body, as: UTF8.self) + return text.range(of: #""tools":"#).map { String(text[$0.lowerBound...]) } + } + + @Test func theRegistryListsToolsByName() async { + let registry = ToolRegistry() + let names = ["zeta", "alpha", "mu", "beta", "omega", "kappa", "delta", "pi", "eta", "nu", "chi", "rho"] + for name in names { await registry.register(SortTestTool(name: name)) } + #expect(await registry.allTools().map(\.name) == names.sorted()) + #expect(await registry.allToolNames() == names.sorted()) + } + + /// Registrations run in call order: an app that replaces a framework tool + /// (Naseem's learn_skill) gets the same tool on every engine. + @Test func aLaterRegistrationOfANameAlwaysWins() async { + for _ in 0..<50 { + let agent = Agent(config: AgentConfig(provider: ScriptedProvider(turns: []), model: "mock")) + await agent.register(NamedTool(name: "learn_skill", description: "framework")) + await agent.registerAll([NamedTool(name: "learn_skill", description: "app")]) + await agent.register(NamedTool(name: "zeta", description: "z")) + let tools = await agent.registeredTools() + #expect(tools.first { $0.name == "learn_skill" }?.description == "app") + #expect(tools.map(\.name) == ["learn_skill", "zeta"]) + } + } + + /// Naseem's order: `setSkillStore` (the framework's learn_skill, then a + /// slow skill load), other setup, then the app's own learn_skill. The + /// app's must win while the earlier work is still running: it waits for + /// ALL of it, not only for the registration queued right before it. + @Test func aLaterRegistrationWaitsForEverythingQueuedBeforeIt() async throws { + let agent = Agent(config: AgentConfig(provider: ScriptedProvider(turns: []), model: "mock")) + let gate = GatedSkillStore() + try await agent.setSkillStore(gate) // learn_skill (framework), use_skill, the gated load + await agent.register(NamedTool(name: "learn_skill", description: "framework")) + await agent.setToolContext([:]) // unrelated work that finishes at once + await agent.register(NamedTool(name: "learn_skill", description: "app")) + try await Task.sleep(nanoseconds: 100_000_000) + // The load is still blocked, so nothing queued after it has landed. + #expect(await agent.tools.allTools().first { $0.name == "learn_skill" }?.description != "app") + gate.open() + let tools = await agent.registeredTools() + #expect(tools.first { $0.name == "learn_skill" }?.description == "app") + } + + @Test func theDigestIsStableAcrossLaunches() { + // FNV-1a 64 is unseeded: the same text gives the same digest in any process. + #expect(PromptDigest.hex("abc") == "e71fa2190541574b") + #expect(PromptDigest.hex("") == "cbf29ce484222325") + } + + @Test func theFirstCallOfARunRecordsItsSystemDigest() async throws { + let provider = ScriptedProvider(turns: [[LLMToolCall(id: "r1", name: "read_file", arguments: #"{"path":"/a"}"#)], []]) + let agent = Agent(config: AgentConfig(provider: provider, model: "mock", systemPrompt: "BASE", + maxTurns: 3, tools: [ReadTool()], loopDetection: nil)) + #expect(await agent.firstRequestSystemDigest == nil) + _ = try await agent.run("hi") + let system = provider.captured[0].messages.first { $0.role == .system }?.content ?? "" + #expect(!system.isEmpty) + #expect(await agent.firstRequestSystemDigest == PromptDigest.hex(system)) + } + + /// Two runs of one chat, two calls each: every request's wire body starts + /// with the previous request's messages, byte for byte, and the tool + /// definitions never change. + @Test func consecutiveCallsAreAppendOnlyOnTheWire() async throws { + let provider = ScriptedProvider(turns: [ + [LLMToolCall(id: "e1", name: "edit_file", arguments: #"{"path":"/a","old_text":"x","new_text":"y"}"#)], + [], + [LLMToolCall(id: "r1", name: "read_file", arguments: #"{"path":"/a","limit":1}"#)], + [], + ]) + let agent = Agent(config: AgentConfig(provider: provider, model: "mock", systemPrompt: "BASE", + maxTurns: 5, tools: [EditTool(), ReadTool()], loopDetection: nil)) + _ = try await agent.run("change x to y in /a") + _ = try await agent.run("now read /a") + + let bodies = try provider.captured.map(Self.wireBody) + #expect(bodies.count == 4) + for (earlier, later) in zip(bodies, bodies.dropFirst()) { + let prefix = try #require(Self.messagesPrefix(earlier)) + #expect(later.starts(with: prefix), "a later request rewrote an earlier byte") + #expect(Self.toolsSection(later) == Self.toolsSection(earlier)) + } + // The replayed `limit` is the integer the model sent, not `true` + // (inside the body the arguments are a JSON string, so quotes are escaped). + #expect(String(decoding: bodies[3], as: UTF8.self).contains(#"\"limit\":1,"#)) + } +} From 6ad0bd967d6cb8db8d04e938bd8a08dff0a383cb Mon Sep 17 00:00:00 2001 From: Ayman Hamed Date: Fri, 9 Oct 2026 07:45:57 +0300 Subject: [PATCH 4/4] Reset the first-request system digest at run start Co-Authored-By: Claude Fable 5.1 --- Sources/SwiftAgentKit/Core/Agent.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/SwiftAgentKit/Core/Agent.swift b/Sources/SwiftAgentKit/Core/Agent.swift index 591857d..d03bb4b 100644 --- a/Sources/SwiftAgentKit/Core/Agent.swift +++ b/Sources/SwiftAgentKit/Core/Agent.swift @@ -963,6 +963,7 @@ public actor Agent { await awaitPendingRegistrations() resetCancellation() firstRequestPending = true + firstRequestSystemDigest = nil // this run's, never the previous run's let startTime = Date() emit(.started(query: query))