main
swift 703 lines 20.1 KB
Raw
1 import Foundation
2
3 /// A capability your app makes available to the local model.
4 ///
5 /// Ed never invents native capabilities. Your app registers each tool and
6 /// remains responsible for executing it.
7 public protocol EdTool: Sendable {
8 var definition: EdToolDefinition { get }
9
10 /// Executes the tool with the JSON object produced by the model.
11 func execute(arguments: String) async throws -> String
12 }
13
14 public enum EdToolRisk: Sendable {
15 /// Safe to run without asking, such as reading app state.
16 case readOnly
17
18 /// Changes state and must be approved first, such as saving or deleting.
19 case mutating
20 }
21
22 public struct EdToolDefinition: Sendable {
23 public let name: String
24 public let description: String
25 public let parametersSchema: String
26 public let risk: EdToolRisk
27
28 public init(
29 name: String,
30 description: String,
31 parametersSchema: String,
32 risk: EdToolRisk
33 ) {
34 self.name = name
35 self.description = description
36 self.parametersSchema = parametersSchema
37 self.risk = risk
38 }
39
40 public static func readOnly(
41 name: String,
42 description: String,
43 parametersSchema: String
44 ) -> Self {
45 Self(
46 name: name,
47 description: description,
48 parametersSchema: parametersSchema,
49 risk: .readOnly
50 )
51 }
52
53 public static func mutating(
54 name: String,
55 description: String,
56 parametersSchema: String
57 ) -> Self {
58 Self(
59 name: name,
60 description: description,
61 parametersSchema: parametersSchema,
62 risk: .mutating
63 )
64 }
65 }
66
67 public struct EdToolCall: Sendable, Equatable {
68 public let id: String
69 public let name: String
70 public let arguments: String
71 }
72
73 public enum EdApprovalDecision: Sendable {
74 case allowOnce
75 case allowForSession
76 case deny
77 }
78
79 public typealias EdApprovalHandler = @Sendable (EdToolCall) async -> EdApprovalDecision
80
81 /// Like ``EdApprovalHandler``, and also told the tool's risk, so the approval
82 /// sheet can say whether the call changes anything.
83 public typealias EdRiskApprovalHandler =
84 @Sendable (EdToolCall, EdToolRisk) async -> EdApprovalDecision
85
86 public struct EdAgentConfiguration: Sendable {
87 public var maxToolRounds: UInt8
88 public var maxToolOutputCharacters: UInt64
89
90 public init(maxToolRounds: UInt8 = 8, maxToolOutputCharacters: UInt64 = 10_000) {
91 self.maxToolRounds = maxToolRounds
92 self.maxToolOutputCharacters = maxToolOutputCharacters
93 }
94 }
95
96 public struct EdAgentReply: Sendable, Equatable {
97 public let text: String
98 public let durationSeconds: Double
99 public let duration: String
100 public let toolRounds: UInt8
101 }
102
103 public struct EdReply: Sendable, Equatable {
104 public let text: String
105 public let duration: String
106 }
107
108 public enum EdEngineStatus: Sendable, Equatable {
109 case unloaded
110 case loading
111 case ready
112 case generating
113 case error
114 }
115
116 public struct EdEngineInfo: Sendable, Equatable {
117 public let status: EdEngineStatus
118 public let modelName: String?
119 public let approximateMemory: String?
120 public let historyLength: UInt64
121
122 public init(
123 status: EdEngineStatus,
124 modelName: String? = nil,
125 approximateMemory: String? = nil,
126 historyLength: UInt64 = 0
127 ) {
128 self.status = status
129 self.modelName = modelName
130 self.approximateMemory = approximateMemory
131 self.historyLength = historyLength
132 }
133 }
134
135 public enum EdChatRole: Sendable, Equatable {
136 case system
137 case user
138 case assistant
139 }
140
141 public struct EdChatMessage: Sendable, Equatable {
142 public let role: EdChatRole
143 public let content: String
144
145 public init(role: EdChatRole, content: String) {
146 self.role = role
147 self.content = content
148 }
149 }
150
151 public enum EdEvent: Sendable {
152 case statusChanged(status: EdEngineStatus, modelName: String?, error: String?)
153
154 /// A piece of the reply as it is generated during ``EdAgent/run(_:)``.
155 case textDelta(String)
156 case toolRequested(EdToolCall)
157 case approvalRequested(EdToolCall)
158 case toolStarted(EdToolCall)
159 case toolFinished(callID: String, content: String, isError: Bool)
160 case agentReplied(EdAgentReply)
161 case warning(String)
162 }
163
164 public struct EdSamplingConfiguration: Sendable {
165 public var temperature: Double?
166 public var topP: Double?
167 public var topK: UInt64?
168 public var minP: Double?
169 public var maxTokens: UInt64?
170 public var frequencyPenalty: Float?
171 public var presencePenalty: Float?
172
173 public init(
174 temperature: Double? = nil,
175 topP: Double? = nil,
176 topK: UInt64? = nil,
177 minP: Double? = nil,
178 maxTokens: UInt64? = nil,
179 frequencyPenalty: Float? = nil,
180 presencePenalty: Float? = nil
181 ) {
182 self.temperature = temperature
183 self.topP = topP
184 self.topK = topK
185 self.minP = minP
186 self.maxTokens = maxTokens
187 self.frequencyPenalty = frequencyPenalty
188 self.presencePenalty = presencePenalty
189 }
190 }
191
192 public struct EdGGUFModelConfiguration: Sendable {
193 public var modelID: String
194 public var files: [String]
195 public var tokenizerModelID: String?
196 public var displayName: String
197 public var approximateMemory: String
198 public var chatTemplate: String?
199
200 public init(
201 modelID: String,
202 files: [String],
203 tokenizerModelID: String? = nil,
204 displayName: String,
205 approximateMemory: String,
206 chatTemplate: String? = nil
207 ) {
208 self.modelID = modelID
209 self.files = files
210 self.tokenizerModelID = tokenizerModelID
211 self.displayName = displayName
212 self.approximateMemory = approximateMemory
213 self.chatTemplate = chatTemplate
214 }
215 }
216
217 public struct EdUQFFModelConfiguration: Sendable {
218 public var modelID: String
219 public var files: [String]
220 public var displayName: String
221 public var approximateMemory: String
222 public var chatTemplate: String?
223
224 public init(
225 modelID: String,
226 files: [String],
227 displayName: String,
228 approximateMemory: String,
229 chatTemplate: String? = nil
230 ) {
231 self.modelID = modelID
232 self.files = files
233 self.displayName = displayName
234 self.approximateMemory = approximateMemory
235 self.chatTemplate = chatTemplate
236 }
237 }
238
239 public enum EdEnvironment: Sendable {
240 case development
241 case production
242 }
243
244 /// A local agent powered by Onde.
245 ///
246 /// The actor protects model history, the tool registry, and inference so your
247 /// UI can call it safely from Swift concurrency.
248 public actor EdAgent {
249 private let core: FfiEdAgent
250 private let tools: ToolDispatcher
251 private let approvals: ApprovalAdapter
252 private nonisolated let eventHub: EventHub
253
254 /// - Parameter appID: Your Onde app id, so the engine reports usage under
255 /// it. Pass `nil` for none.
256 public init(
257 appID: String?,
258 configuration: EdAgentConfiguration = .init(),
259 approvalHandler: @escaping EdRiskApprovalHandler = { _, _ in .deny }
260 ) {
261 let tools = ToolDispatcher()
262 let approvals = ApprovalAdapter(handler: approvalHandler)
263 let eventHub = EventHub()
264
265 self.tools = tools
266 self.approvals = approvals
267 self.eventHub = eventHub
268 self.core = FfiEdAgent.newWithAppId(
269 executor: tools,
270 approvals: approvals,
271 events: eventHub,
272 config: FfiAgentConfig(
273 maxToolRounds: configuration.maxToolRounds,
274 maxToolOutputChars: configuration.maxToolOutputCharacters
275 ),
276 appId: appID
277 )
278 }
279
280 public init(
281 configuration: EdAgentConfiguration = .init(),
282 approvalHandler: @escaping EdApprovalHandler = { _ in .deny }
283 ) {
284 self.init(
285 appID: nil,
286 configuration: configuration,
287 approvalHandler: { call, _ in await approvalHandler(call) }
288 )
289 }
290
291 /// A separate stream for each observer. Stop its task to unsubscribe.
292 public nonisolated func events() -> AsyncStream<EdEvent> {
293 eventHub.makeStream()
294 }
295
296 public func register(_ tool: any EdTool) async throws {
297 try await core.registerTool(tool: tool.definition.ffi)
298 tools.set(tool)
299 }
300
301 @discardableResult
302 public func unregisterTool(named name: String) async -> Bool {
303 let removed = await core.unregisterTool(name: name)
304 if removed {
305 tools.remove(named: name)
306 }
307 return removed
308 }
309
310 public func removeAllTools() async {
311 await core.removeAllTools()
312 tools.removeAll()
313 }
314
315 /// Runs the full agent loop: model -> tool calls -> tool results -> model.
316 public func run(_ message: String) async throws -> EdAgentReply {
317 try await withTaskCancellationHandler {
318 try await core.run(message: message).swift
319 } onCancel: {
320 core.cancel()
321 }
322 }
323
324 /// Sends one ordinary chat turn without exposing tools to the model.
325 public func send(_ message: String) async throws -> EdReply {
326 let reply = try await core.send(message: message)
327 return EdReply(text: reply.text, duration: reply.duration)
328 }
329
330 /// Streams the reply to one ordinary chat turn, without exposing tools.
331 ///
332 /// Yields text deltas as they are generated. Cancelling the consuming task
333 /// (or breaking out of the loop) stops generation; what was produced stays
334 /// in the history.
335 public nonisolated func stream(_ message: String) -> AsyncThrowingStream<String, Error> {
336 let core = self.core
337 return AsyncThrowingStream { continuation in
338 let task = Task {
339 do {
340 try await core.streamMessage(
341 message: message,
342 listener: StreamAdapter(continuation: continuation)
343 )
344 continuation.finish()
345 } catch {
346 continuation.finish(throwing: error)
347 }
348 }
349 continuation.onTermination = { _ in
350 core.cancelStream()
351 task.cancel()
352 }
353 }
354 }
355
356 /// One-shot generation over `messages`. The conversation is left alone.
357 public func generate(
358 messages: [EdChatMessage],
359 sampling: EdSamplingConfiguration? = nil
360 ) async throws -> String {
361 try await core.generate(messages: messages.map(\.ffi), sampling: sampling?.ffi)
362 }
363
364 /// Replaces the conversation with `messages`, for switching to a saved chat.
365 /// Needs a loaded model. Also revokes any "allow for session" approvals.
366 public func restoreHistory(_ messages: [EdChatMessage]) async {
367 await core.restoreHistory(messages: messages.map(\.ffi))
368 }
369
370 /// Sets the system prompt for later turns. Needs a loaded model.
371 public func setSystemPrompt(_ prompt: String) async {
372 await core.setSystemPrompt(prompt: prompt)
373 }
374
375 public func clearSystemPrompt() async {
376 await core.clearSystemPrompt()
377 }
378
379 public func setSampling(_ sampling: EdSamplingConfiguration) async {
380 await core.setSampling(sampling: sampling.ffi)
381 }
382
383 /// Loads a small tool-capable model chosen for the current Apple device.
384 @discardableResult
385 public func loadDefaultAgentModel(systemPrompt: String? = nil) async throws -> Double {
386 try await core.loadDefaultAgentModel(systemPrompt: systemPrompt)
387 }
388
389 @discardableResult
390 public func load(
391 gguf config: EdGGUFModelConfiguration,
392 systemPrompt: String? = nil,
393 sampling: EdSamplingConfiguration? = nil
394 ) async throws -> Double {
395 try await core.loadGgufModel(
396 config: config.ffi,
397 systemPrompt: systemPrompt,
398 sampling: sampling?.ffi
399 )
400 }
401
402 @discardableResult
403 public func load(
404 uqff config: EdUQFFModelConfiguration,
405 systemPrompt: String? = nil,
406 sampling: EdSamplingConfiguration? = nil
407 ) async throws -> Double {
408 try await core.loadUqffModel(
409 config: config.ffi,
410 systemPrompt: systemPrompt,
411 sampling: sampling?.ffi
412 )
413 }
414
415 @discardableResult
416 public func loadAssignedModel(
417 environment: EdEnvironment,
418 appID: String,
419 appSecret: String,
420 systemPrompt: String? = nil,
421 sampling: EdSamplingConfiguration? = nil
422 ) async throws -> Double {
423 try await core.loadAssignedModel(
424 environment: environment.ffi,
425 appId: appID,
426 appSecret: appSecret,
427 systemPrompt: systemPrompt,
428 sampling: sampling?.ffi
429 )
430 }
431
432 public nonisolated func cancel() {
433 core.cancel()
434 }
435
436 @discardableResult
437 public func unload() async -> String? {
438 await core.unload()
439 }
440
441 public func isLoaded() async -> Bool {
442 await core.isLoaded()
443 }
444
445 public func info() async -> EdEngineInfo {
446 let info = await core.info()
447 return EdEngineInfo(
448 status: info.status.swift,
449 modelName: info.modelName,
450 approximateMemory: info.approxMemory,
451 historyLength: info.historyLength
452 )
453 }
454
455 public func history() async -> [EdChatMessage] {
456 await core.history().map(\.swift)
457 }
458
459 @discardableResult
460 public func clearHistory() async -> UInt64 {
461 await core.clearHistory()
462 }
463
464 public static func configureCacheDirectory(_ url: URL) {
465 configureCacheDir(path: url.path)
466 }
467
468 public static func defaultAgentModelConfiguration() -> EdGGUFModelConfiguration {
469 defaultAgentModelConfig().swift
470 }
471 }
472
473 private final class ToolDispatcher: FfiToolExecutor, @unchecked Sendable {
474 private let lock = NSLock()
475 private var tools: [String: any EdTool] = [:]
476
477 func set(_ tool: any EdTool) {
478 lock.withLock { tools[tool.definition.name] = tool }
479 }
480
481 func remove(named name: String) {
482 lock.withLock { _ = tools.removeValue(forKey: name) }
483 }
484
485 func removeAll() {
486 lock.withLock { tools.removeAll() }
487 }
488
489 func execute(toolName: String, arguments: String) async -> FfiToolOutput {
490 let tool = lock.withLock { tools[toolName] }
491 guard let tool else {
492 return FfiToolOutput(content: "", error: "Unknown tool: \(toolName)")
493 }
494
495 do {
496 return FfiToolOutput(
497 content: try await tool.execute(arguments: arguments),
498 error: nil
499 )
500 } catch {
501 return FfiToolOutput(content: "", error: String(describing: error))
502 }
503 }
504 }
505
506 private final class ApprovalAdapter: FfiApprovalHandler, @unchecked Sendable {
507 private let handler: EdRiskApprovalHandler
508
509 init(handler: @escaping EdRiskApprovalHandler) {
510 self.handler = handler
511 }
512
513 func approve(request: FfiApprovalRequest) async -> FfiApprovalDecision {
514 await handler(request.call.swift, request.risk.swift).ffi
515 }
516 }
517
518 private final class StreamAdapter: FfiStreamListener, @unchecked Sendable {
519 private let continuation: AsyncThrowingStream<String, Error>.Continuation
520
521 init(continuation: AsyncThrowingStream<String, Error>.Continuation) {
522 self.continuation = continuation
523 }
524
525 func onDelta(delta: String) {
526 continuation.yield(delta)
527 }
528 }
529
530 private final class EventHub: FfiEventListener, @unchecked Sendable {
531 private let lock = NSLock()
532 private var continuations: [UUID: AsyncStream<EdEvent>.Continuation] = [:]
533
534 func makeStream() -> AsyncStream<EdEvent> {
535 let id = UUID()
536 return AsyncStream { continuation in
537 lock.withLock { continuations[id] = continuation }
538 continuation.onTermination = { [weak self] _ in
539 self?.lock.withLock { _ = self?.continuations.removeValue(forKey: id) }
540 }
541 }
542 }
543
544 func statusChanged(update: FfiStatusUpdate) {
545 emit(.statusChanged(
546 status: update.status.swift,
547 modelName: update.modelName,
548 error: update.error
549 ))
550 }
551
552 func textDelta(delta: String) { emit(.textDelta(delta)) }
553 func toolRequested(call: FfiToolCall) { emit(.toolRequested(call.swift)) }
554 func approvalRequested(request: FfiApprovalRequest) { emit(.approvalRequested(request.call.swift)) }
555 func toolStarted(call: FfiToolCall) { emit(.toolStarted(call.swift)) }
556
557 func toolFinished(toolCallId: String, content: String, isError: Bool) {
558 emit(.toolFinished(callID: toolCallId, content: content, isError: isError))
559 }
560
561 func agentReplied(reply: FfiAgentReply) { emit(.agentReplied(reply.swift)) }
562 func warning(message: String) { emit(.warning(message)) }
563
564 private func emit(_ event: EdEvent) {
565 let current = lock.withLock { Array(continuations.values) }
566 current.forEach { $0.yield(event) }
567 }
568 }
569
570 private extension EdToolDefinition {
571 var ffi: FfiToolDefinition {
572 FfiToolDefinition(
573 name: name,
574 description: description,
575 parametersSchema: parametersSchema,
576 risk: risk == .readOnly ? .readOnly : .mutating
577 )
578 }
579 }
580
581 private extension FfiToolRisk {
582 var swift: EdToolRisk { self == .readOnly ? .readOnly : .mutating }
583 }
584
585 private extension EdChatMessage {
586 var ffi: FfiChatMessage {
587 let role: FfiChatRole = switch self.role {
588 case .system: .system
589 case .user: .user
590 case .assistant: .assistant
591 }
592 return FfiChatMessage(role: role, content: content)
593 }
594 }
595
596 private extension FfiToolCall {
597 var swift: EdToolCall { EdToolCall(id: id, name: name, arguments: arguments) }
598 }
599
600 private extension EdApprovalDecision {
601 var ffi: FfiApprovalDecision {
602 switch self {
603 case .allowOnce: .allowOnce
604 case .allowForSession: .allowForSession
605 case .deny: .deny
606 }
607 }
608 }
609
610 private extension FfiAgentReply {
611 var swift: EdAgentReply {
612 EdAgentReply(
613 text: text,
614 durationSeconds: durationSeconds,
615 duration: duration,
616 toolRounds: toolRounds
617 )
618 }
619 }
620
621 private extension FfiEngineStatus {
622 var swift: EdEngineStatus {
623 switch self {
624 case .unloaded: .unloaded
625 case .loading: .loading
626 case .ready: .ready
627 case .generating: .generating
628 case .error: .error
629 }
630 }
631 }
632
633 private extension FfiChatMessage {
634 var swift: EdChatMessage {
635 let role: EdChatRole = switch self.role {
636 case .system: .system
637 case .user: .user
638 case .assistant: .assistant
639 }
640 return EdChatMessage(role: role, content: content)
641 }
642 }
643
644 private extension EdSamplingConfiguration {
645 var ffi: FfiSamplingConfig {
646 FfiSamplingConfig(
647 temperature: temperature,
648 topP: topP,
649 topK: topK,
650 minP: minP,
651 maxTokens: maxTokens,
652 frequencyPenalty: frequencyPenalty,
653 presencePenalty: presencePenalty
654 )
655 }
656 }
657
658 private extension EdGGUFModelConfiguration {
659 var ffi: FfiGgufModelConfig {
660 FfiGgufModelConfig(
661 modelId: modelID,
662 files: files,
663 tokModelId: tokenizerModelID,
664 displayName: displayName,
665 approxMemory: approximateMemory,
666 chatTemplate: chatTemplate
667 )
668 }
669 }
670
671 private extension FfiGgufModelConfig {
672 var swift: EdGGUFModelConfiguration {
673 EdGGUFModelConfiguration(
674 modelID: modelId,
675 files: files,
676 tokenizerModelID: tokModelId,
677 displayName: displayName,
678 approximateMemory: approxMemory,
679 chatTemplate: chatTemplate
680 )
681 }
682 }
683
684 private extension EdUQFFModelConfiguration {
685 var ffi: FfiUqffModelConfig {
686 FfiUqffModelConfig(
687 modelId: modelID,
688 files: files,
689 displayName: displayName,
690 approxMemory: approximateMemory,
691 chatTemplate: chatTemplate
692 )
693 }
694 }
695
696 private extension EdEnvironment {
697 var ffi: FfiEnvironment {
698 switch self {
699 case .development: .development
700 case .production: .production
701 }
702 }
703 }