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
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.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"),
Expand Down
84 changes: 66 additions & 18 deletions Sources/SwiftAgentKit/Core/Agent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,12 @@ public actor Agent {
/// and every post-init access is actor-isolated.
private nonisolated(unsafe) var pendingRegistrationTasks: [Task<Void, Never>] = []

/// 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<Void, Never>?

/// Logger.
public private(set) var logger: AgentLogger

Expand All @@ -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

Expand Down Expand Up @@ -479,27 +494,27 @@ 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) }
}
}

// Register the context manager's artifact-retrieval tools so the model
// 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
Expand All @@ -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) }
}
}

Expand Down Expand Up @@ -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()
Expand All @@ -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 }
Expand All @@ -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<Void, Never>) {
/// 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)
}

Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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 ?? "",
Expand Down
29 changes: 24 additions & 5 deletions Sources/SwiftAgentKit/Core/AgentMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions Sources/SwiftAgentKit/Core/PromptDigest.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
11 changes: 7 additions & 4 deletions Sources/SwiftAgentKit/Tools/AgentTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading