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"), diff --git a/Sources/SwiftAgentKit/Core/Agent.swift b/Sources/SwiftAgentKit/Core/Agent.swift index ce390cf..d03bb4b 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,8 @@ 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)) @@ -1766,6 +1809,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/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/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 new file mode 100644 index 0000000..a45ea0f --- /dev/null +++ b/Tests/SwiftAgentKitTests/CacheStableRequestTests.swift @@ -0,0 +1,258 @@ +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}"#) + } +} + +/// 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,"#)) + } +}