Ed 1.1: streaming, history restore, generate, prompt and sampling setters
Adds EdAgent(appID:), stream(_:), restoreHistory(_:), generate(messages:sampling:), setSystemPrompt, clearSystemPrompt and setSampling, EdEvent.textDelta, and an approval handler form that also receives the tool's risk. The generated FFI glue is rebuilt from the 1.1 Rust crates. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Oct 8, 2026 at 22:58 UTC
6a84ea67cfaa71339d733bf25fb1e5d0b524784a
4 files changed
+568
-16
README.md
+24
@@ -87,6 +87,30 @@ for await event in ed.events() {
87
}
88
```
89
90
+### Plain chat, streaming and saved conversations
91
+
92
+For chat without tools, `stream` yields the reply as it is generated. Breaking
93
+out of the loop or cancelling the task stops generation:
94
+
95
+```swift
96
+for try await delta in ed.stream("Summarise this thread.") {
97
+ reply += delta
98
+}
99
+```
100
+
101
+Other calls for apps with their own chat history:
102
+
103
+```swift
104
+await ed.restoreHistory(savedMessages) // switch to a saved chat (needs a loaded model)
105
+let draft = try await ed.generate(messages: messages) // one-shot, history untouched
106
+await ed.setSystemPrompt("Be brief.")
107
+await ed.setSampling(EdSamplingConfiguration(temperature: 0.2))
108
+```
109
+
110
+`EdAgent(appID:)` reports usage under your Onde app id. Pass a two-argument
111
+approval handler (`{ call, risk in ... }`) to learn whether a call is read-only
112
+or mutating, and watch for `EdEvent.textDelta` while `run` generates.
113
+
114
Cancelling the Swift task also cancels the active Ed turn:
115
116
```swift
Sources/Ed/EdAgent.swift
+131
-10
@@ -78,6 +78,11 @@ public enum EdApprovalDecision: Sendable {
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
@@ -100,7 +105,7 @@ public struct EdReply: Sendable, Equatable {
105
public let duration: String
106
}
107
103
-public enum EdEngineStatus: Sendable {
108
+public enum EdEngineStatus: Sendable, Equatable {
109
case unloaded
110
case loading
111
case ready
@@ -108,26 +113,46 @@ public enum EdEngineStatus: Sendable {
113
case error
114
}
115
111
-public struct EdEngineInfo: Sendable {
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
118
-public enum EdChatRole: Sendable {
135
+public enum EdChatRole: Sendable, Equatable {
136
case system
137
case user
138
case assistant
139
}
140
124
-public struct EdChatMessage: Sendable {
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)
@@ -226,9 +251,12 @@ public actor EdAgent {
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(),
231
- approvalHandler: @escaping EdApprovalHandler = { _ in .deny }
259
+ approvalHandler: @escaping EdRiskApprovalHandler = { _, _ in .deny }
260
) {
261
let tools = ToolDispatcher()
262
let approvals = ApprovalAdapter(handler: approvalHandler)
@@ -237,14 +265,26 @@ public actor EdAgent {
265
self.tools = tools
266
self.approvals = approvals
267
self.eventHub = eventHub
240
- self.core = FfiEdAgent(
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
247
- )
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
@@ -287,6 +327,59 @@ public actor EdAgent {
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 {
@@ -411,14 +504,26 @@ private final class ToolDispatcher: FfiToolExecutor, @unchecked Sendable {
504
}
505
506
private final class ApprovalAdapter: FfiApprovalHandler, @unchecked Sendable {
414
- private let handler: EdApprovalHandler
507
+ private let handler: EdRiskApprovalHandler
508
416
- init(handler: @escaping EdApprovalHandler) {
509
+ init(handler: @escaping EdRiskApprovalHandler) {
510
self.handler = handler
511
}
512
513
func approve(request: FfiApprovalRequest) async -> FfiApprovalDecision {
421
- await handler(request.call.swift).ffi
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
@@ -444,6 +549,7 @@ private final class EventHub: FfiEventListener, @unchecked Sendable {
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)) }
@@ -472,6 +578,21 @@ private extension EdToolDefinition {
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
}
Sources/Ed/ed_agent_ffi.swift
+363
-6
@@ -558,8 +558,20 @@ public protocol FfiEdAgentProtocol: AnyObject, Sendable {
558
559
func cancel()
560
561
+ /**
562
+ * Stop the `stream_message` in progress. Does nothing when none is.
563
+ */
564
+ func cancelStream()
565
+
566
func clearHistory() async -> UInt64
567
568
+ func clearSystemPrompt() async
569
+
570
+ /**
571
+ * One-shot generation over `messages`. Leaves the conversation untouched.
572
+ */
573
+ func generate(messages: [FfiChatMessage], sampling: FfiSamplingConfig?) async throws -> String
574
+
575
func history() async -> [FfiChatMessage]
576
577
func info() async -> FfiEngineInfo
@@ -578,10 +590,26 @@ public protocol FfiEdAgentProtocol: AnyObject, Sendable {
590
591
func removeAllTools() async
592
593
+ /**
594
+ * Replace the conversation with `messages`. Needs a loaded model.
595
+ */
596
+ func restoreHistory(messages: [FfiChatMessage]) async
597
+
598
func run(message: String) async throws -> FfiAgentReply
599
600
func send(message: String) async throws -> FfiReply
601
602
+ func setSampling(sampling: FfiSamplingConfig) async
603
+
604
+ func setSystemPrompt(prompt: String) async
605
+
606
+ /**
607
+ * Stream the reply to `message` into `listener`, for plain chat without tools.
608
+ * Returns when the reply is complete, on error, or after `cancel_stream`.
609
+ * The turn so far stays in history either way.
610
+ */
611
+ func streamMessage(message: String, listener: FfiStreamListener) async throws
612
+
613
func unload() async -> String?
614
615
func unregisterTool(name: String) async -> Bool
@@ -649,6 +677,21 @@ public convenience init(executor: FfiToolExecutor, approvals: FfiApprovalHandler
677
}
678
679
680
+ /**
681
+ * Like `new`, with an Onde app id so the engine reports usage under it.
682
+ */
683
+public static func newWithAppId(executor: FfiToolExecutor, approvals: FfiApprovalHandler, events: FfiEventListener, config: FfiAgentConfig?, appId: String?) -> FfiEdAgent {
684
+ return try! FfiConverterTypeFfiEdAgent_lift(try! rustCall() {
685
+ uniffi_ed_agent_ffi_fn_constructor_ffiedagent_new_with_app_id(
686
+ FfiConverterCallbackInterfaceFfiToolExecutor_lower(executor),
687
+ FfiConverterCallbackInterfaceFfiApprovalHandler_lower(approvals),
688
+ FfiConverterCallbackInterfaceFfiEventListener_lower(events),
689
+ FfiConverterOptionTypeFfiAgentConfig.lower(config),
690
+ FfiConverterOptionString.lower(appId),$0
691
+ )
692
+})
693
+}
694
+
695
696
697
open func cancel() {try! rustCall() {
@@ -656,6 +699,16 @@ open func cancel() {try! rustCall() {
699
self.uniffiCloneHandle(),$0
700
)
701
}
702
+}
703
+
704
+ /**
705
+ * Stop the `stream_message` in progress. Does nothing when none is.
706
+ */
707
+open func cancelStream() {try! rustCall() {
708
+ uniffi_ed_agent_ffi_fn_method_ffiedagent_cancel_stream(
709
+ self.uniffiCloneHandle(),$0
710
+ )
711
+}
712
}
713
714
open func clearHistory()async -> UInt64 {
@@ -676,6 +729,44 @@ open func clearHistory()async -> UInt64 {
729
)
730
}
731
732
+open func clearSystemPrompt()async {
733
+ return
734
+ try! await uniffiRustCallAsync(
735
+ rustFutureFunc: {
736
+ uniffi_ed_agent_ffi_fn_method_ffiedagent_clear_system_prompt(
737
+ self.uniffiCloneHandle()
738
+
739
+ )
740
+ },
741
+ pollFunc: ffi_ed_agent_ffi_rust_future_poll_void,
742
+ completeFunc: ffi_ed_agent_ffi_rust_future_complete_void,
743
+ freeFunc: ffi_ed_agent_ffi_rust_future_free_void,
744
+ liftFunc: { $0 },
745
+ errorHandler: nil
746
+
747
+ )
748
+}
749
+
750
+ /**
751
+ * One-shot generation over `messages`. Leaves the conversation untouched.
752
+ */
753
+open func generate(messages: [FfiChatMessage], sampling: FfiSamplingConfig?)async throws -> String {
754
+ return
755
+ try await uniffiRustCallAsync(
756
+ rustFutureFunc: {
757
+ uniffi_ed_agent_ffi_fn_method_ffiedagent_generate(
758
+ self.uniffiCloneHandle(),
759
+ FfiConverterSequenceTypeFfiChatMessage.lower(messages),FfiConverterOptionTypeFfiSamplingConfig.lower(sampling)
760
+ )
761
+ },
762
+ pollFunc: ffi_ed_agent_ffi_rust_future_poll_rust_buffer,
763
+ completeFunc: ffi_ed_agent_ffi_rust_future_complete_rust_buffer,
764
+ freeFunc: ffi_ed_agent_ffi_rust_future_free_rust_buffer,
765
+ liftFunc: FfiConverterString.lift,
766
+ errorHandler: FfiConverterTypeFfiEdError_lift
767
+ )
768
+}
769
+
770
open func history()async -> [FfiChatMessage] {
771
return
772
try! await uniffiRustCallAsync(
@@ -833,6 +924,27 @@ open func removeAllTools()async {
924
)
925
}
926
927
+ /**
928
+ * Replace the conversation with `messages`. Needs a loaded model.
929
+ */
930
+open func restoreHistory(messages: [FfiChatMessage])async {
931
+ return
932
+ try! await uniffiRustCallAsync(
933
+ rustFutureFunc: {
934
+ uniffi_ed_agent_ffi_fn_method_ffiedagent_restore_history(
935
+ self.uniffiCloneHandle(),
936
+ FfiConverterSequenceTypeFfiChatMessage.lower(messages)
937
+ )
938
+ },
939
+ pollFunc: ffi_ed_agent_ffi_rust_future_poll_void,
940
+ completeFunc: ffi_ed_agent_ffi_rust_future_complete_void,
941
+ freeFunc: ffi_ed_agent_ffi_rust_future_free_void,
942
+ liftFunc: { $0 },
943
+ errorHandler: nil
944
+
945
+ )
946
+}
947
+
948
open func run(message: String)async throws -> FfiAgentReply {
949
return
950
try await uniffiRustCallAsync(
@@ -867,6 +979,64 @@ open func send(message: String)async throws -> FfiReply {
979
)
980
}
981
982
+open func setSampling(sampling: FfiSamplingConfig)async {
983
+ return
984
+ try! await uniffiRustCallAsync(
985
+ rustFutureFunc: {
986
+ uniffi_ed_agent_ffi_fn_method_ffiedagent_set_sampling(
987
+ self.uniffiCloneHandle(),
988
+ FfiConverterTypeFfiSamplingConfig_lower(sampling)
989
+ )
990
+ },
991
+ pollFunc: ffi_ed_agent_ffi_rust_future_poll_void,
992
+ completeFunc: ffi_ed_agent_ffi_rust_future_complete_void,
993
+ freeFunc: ffi_ed_agent_ffi_rust_future_free_void,
994
+ liftFunc: { $0 },
995
+ errorHandler: nil
996
+
997
+ )
998
+}
999
+
1000
+open func setSystemPrompt(prompt: String)async {
1001
+ return
1002
+ try! await uniffiRustCallAsync(
1003
+ rustFutureFunc: {
1004
+ uniffi_ed_agent_ffi_fn_method_ffiedagent_set_system_prompt(
1005
+ self.uniffiCloneHandle(),
1006
+ FfiConverterString.lower(prompt)
1007
+ )
1008
+ },
1009
+ pollFunc: ffi_ed_agent_ffi_rust_future_poll_void,
1010
+ completeFunc: ffi_ed_agent_ffi_rust_future_complete_void,
1011
+ freeFunc: ffi_ed_agent_ffi_rust_future_free_void,
1012
+ liftFunc: { $0 },
1013
+ errorHandler: nil
1014
+
1015
+ )
1016
+}
1017
+
1018
+ /**
1019
+ * Stream the reply to `message` into `listener`, for plain chat without tools.
1020
+ * Returns when the reply is complete, on error, or after `cancel_stream`.
1021
+ * The turn so far stays in history either way.
1022
+ */
1023
+open func streamMessage(message: String, listener: FfiStreamListener)async throws {
1024
+ return
1025
+ try await uniffiRustCallAsync(
1026
+ rustFutureFunc: {
1027
+ uniffi_ed_agent_ffi_fn_method_ffiedagent_stream_message(
1028
+ self.uniffiCloneHandle(),
1029
+ FfiConverterString.lower(message),FfiConverterCallbackInterfaceFfiStreamListener_lower(listener)
1030
+ )
1031
+ },
1032
+ pollFunc: ffi_ed_agent_ffi_rust_future_poll_void,
1033
+ completeFunc: ffi_ed_agent_ffi_rust_future_complete_void,
1034
+ freeFunc: ffi_ed_agent_ffi_rust_future_free_void,
1035
+ liftFunc: { $0 },
1036
+ errorHandler: FfiConverterTypeFfiEdError_lift
1037
+ )
1038
+}
1039
+
1040
open func unload()async -> String? {
1041
return
1042
try! await uniffiRustCallAsync(
@@ -2332,6 +2502,11 @@ public protocol FfiEventListener: AnyObject, Sendable {
2502
2503
func statusChanged(update: FfiStatusUpdate)
2504
2505
+ /**
2506
+ * A piece of the reply as it is generated, during `run`.
2507
+ */
2508
+ func textDelta(delta: String)
2509
+
2510
func toolRequested(call: FfiToolCall)
2511
2512
func approvalRequested(request: FfiApprovalRequest)
@@ -2387,6 +2562,30 @@ fileprivate struct UniffiCallbackInterfaceFfiEventListener {
2562
}
2563
2564
2565
+ let writeReturn = { () }
2566
+ uniffiTraitInterfaceCall(
2567
+ callStatus: uniffiCallStatus,
2568
+ makeCall: makeCall,
2569
+ writeReturn: writeReturn
2570
+ )
2571
+ },
2572
+ textDelta: { (
2573
+ uniffiHandle: UInt64,
2574
+ delta: RustBuffer,
2575
+ uniffiOutReturn: UnsafeMutableRawPointer,
2576
+ uniffiCallStatus: UnsafeMutablePointer<RustCallStatus>
2577
+ ) in
2578
+ let makeCall = {
2579
+ () throws -> () in
2580
+ guard let uniffiObj = try? FfiConverterCallbackInterfaceFfiEventListener.handleMap.get(handle: uniffiHandle) else {
2581
+ throw UniffiInternalError.unexpectedStaleHandle
2582
+ }
2583
+ return uniffiObj.textDelta(
2584
+ delta: try FfiConverterString.lift(delta)
2585
+ )
2586
+ }
2587
+
2588
+
2589
let writeReturn = { () }
2590
uniffiTraitInterfaceCall(
2591
callStatus: uniffiCallStatus,
@@ -2612,6 +2811,133 @@ public func FfiConverterCallbackInterfaceFfiEventListener_lower(_ v: FfiEventLis
2811
2812
2813
2814
+/**
2815
+ * Receives a streamed reply from `stream_message`.
2816
+ */
2817
+public protocol FfiStreamListener: AnyObject, Sendable {
2818
+
2819
+ func onDelta(delta: String)
2820
+
2821
+}
2822
+
2823
+
2824
+// Put the implementation in a struct so we don't pollute the top-level namespace
2825
+fileprivate struct UniffiCallbackInterfaceFfiStreamListener {
2826
+
2827
+ // Create the VTable using a series of closures.
2828
+ // Swift automatically converts these into C callback functions.
2829
+ //
2830
+ // This creates 1-element array, since this seems to be the only way to construct a const
2831
+ // pointer that we can pass to the Rust code.
2832
+ static let vtable: [UniffiVTableCallbackInterfaceFfiStreamListener] = [UniffiVTableCallbackInterfaceFfiStreamListener(
2833
+ uniffiFree: { (uniffiHandle: UInt64) -> () in
2834
+ do {
2835
+ try FfiConverterCallbackInterfaceFfiStreamListener.handleMap.remove(handle: uniffiHandle)
2836
+ } catch {
2837
+ print("Uniffi callback interface FfiStreamListener: handle missing in uniffiFree")
2838
+ }
2839
+ },
2840
+ uniffiClone: { (uniffiHandle: UInt64) -> UInt64 in
2841
+ do {
2842
+ return try FfiConverterCallbackInterfaceFfiStreamListener.handleMap.clone(handle: uniffiHandle)
2843
+ } catch {
2844
+ fatalError("Uniffi callback interface FfiStreamListener: handle missing in uniffiClone")
2845
+ }
2846
+ },
2847
+ onDelta: { (
2848
+ uniffiHandle: UInt64,
2849
+ delta: RustBuffer,
2850
+ uniffiOutReturn: UnsafeMutableRawPointer,
2851
+ uniffiCallStatus: UnsafeMutablePointer<RustCallStatus>
2852
+ ) in
2853
+ let makeCall = {
2854
+ () throws -> () in
2855
+ guard let uniffiObj = try? FfiConverterCallbackInterfaceFfiStreamListener.handleMap.get(handle: uniffiHandle) else {
2856
+ throw UniffiInternalError.unexpectedStaleHandle
2857
+ }
2858
+ return uniffiObj.onDelta(
2859
+ delta: try FfiConverterString.lift(delta)
2860
+ )
2861
+ }
2862
+
2863
+
2864
+ let writeReturn = { () }
2865
+ uniffiTraitInterfaceCall(
2866
+ callStatus: uniffiCallStatus,
2867
+ makeCall: makeCall,
2868
+ writeReturn: writeReturn
2869
+ )
2870
+ }
2871
+ )]
2872
+}
2873
+
2874
+private func uniffiCallbackInitFfiStreamListener() {
2875
+ uniffi_ed_agent_ffi_fn_init_callback_vtable_ffistreamlistener(UniffiCallbackInterfaceFfiStreamListener.vtable)
2876
+}
2877
+
2878
+// FfiConverter protocol for callback interfaces
2879
+#if swift(>=5.8)
2880
+@_documentation(visibility: private)
2881
+#endif
2882
+fileprivate struct FfiConverterCallbackInterfaceFfiStreamListener {
2883
+ fileprivate static let handleMap = UniffiHandleMap<FfiStreamListener>()
2884
+}
2885
+
2886
+#if swift(>=5.8)
2887
+@_documentation(visibility: private)
2888
+#endif
2889
+extension FfiConverterCallbackInterfaceFfiStreamListener : FfiConverter {
2890
+ typealias SwiftType = FfiStreamListener
2891
+ typealias FfiType = UInt64
2892
+
2893
+#if swift(>=5.8)
2894
+ @_documentation(visibility: private)
2895
+#endif
2896
+ public static func lift(_ handle: UInt64) throws -> SwiftType {
2897
+ try handleMap.get(handle: handle)
2898
+ }
2899
+
2900
+#if swift(>=5.8)
2901
+ @_documentation(visibility: private)
2902
+#endif
2903
+ public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType {
2904
+ let handle: UInt64 = try readInt(&buf)
2905
+ return try lift(handle)
2906
+ }
2907
+
2908
+#if swift(>=5.8)
2909
+ @_documentation(visibility: private)
2910
+#endif
2911
+ public static func lower(_ v: SwiftType) -> UInt64 {
2912
+ return handleMap.insert(obj: v)
2913
+ }
2914
+
2915
+#if swift(>=5.8)
2916
+ @_documentation(visibility: private)
2917
+#endif
2918
+ public static func write(_ v: SwiftType, into buf: inout [UInt8]) {
2919
+ writeInt(&buf, lower(v))
2920
+ }
2921
+}
2922
+
2923
+
2924
+#if swift(>=5.8)
2925
+@_documentation(visibility: private)
2926
+#endif
2927
+public func FfiConverterCallbackInterfaceFfiStreamListener_lift(_ handle: UInt64) throws -> FfiStreamListener {
2928
+ return try FfiConverterCallbackInterfaceFfiStreamListener.lift(handle)
2929
+}
2930
+
2931
+#if swift(>=5.8)
2932
+@_documentation(visibility: private)
2933
+#endif
2934
+public func FfiConverterCallbackInterfaceFfiStreamListener_lower(_ v: FfiStreamListener) -> UInt64 {
2935
+ return FfiConverterCallbackInterfaceFfiStreamListener.lower(v)
2936
+}
2937
+
2938
+
2939
+
2940
+
2941
public protocol FfiToolExecutor: AnyObject, Sendable {
2942
2943
func execute(toolName: String, arguments: String) async -> FfiToolOutput
@@ -3121,9 +3447,18 @@ private let initializationResult: InitializationResult = {
3447
if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_cancel() != 42085) {
3448
return InitializationResult.apiChecksumMismatch
3449
}
3450
+ if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_cancel_stream() != 63923) {
3451
+ return InitializationResult.apiChecksumMismatch
3452
+ }
3453
if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_clear_history() != 19615) {
3454
return InitializationResult.apiChecksumMismatch
3455
}
3456
+ if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_clear_system_prompt() != 50345) {
3457
+ return InitializationResult.apiChecksumMismatch
3458
+ }
3459
+ if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_generate() != 6127) {
3460
+ return InitializationResult.apiChecksumMismatch
3461
+ }
3462
if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_history() != 36014) {
3463
return InitializationResult.apiChecksumMismatch
3464
}
@@ -3151,12 +3486,24 @@ private let initializationResult: InitializationResult = {
3486
if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_remove_all_tools() != 36535) {
3487
return InitializationResult.apiChecksumMismatch
3488
}
3489
+ if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_restore_history() != 25771) {
3490
+ return InitializationResult.apiChecksumMismatch
3491
+ }
3492
if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_run() != 39233) {
3493
return InitializationResult.apiChecksumMismatch
3494
}
3495
if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_send() != 839) {
3496
return InitializationResult.apiChecksumMismatch
3497
}
3498
+ if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_set_sampling() != 44594) {
3499
+ return InitializationResult.apiChecksumMismatch
3500
+ }
3501
+ if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_set_system_prompt() != 31838) {
3502
+ return InitializationResult.apiChecksumMismatch
3503
+ }
3504
+ if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_stream_message() != 6788) {
3505
+ return InitializationResult.apiChecksumMismatch
3506
+ }
3507
if (uniffi_ed_agent_ffi_checksum_method_ffiedagent_unload() != 29612) {
3508
return InitializationResult.apiChecksumMismatch
3509
}
@@ -3166,28 +3513,37 @@ private let initializationResult: InitializationResult = {
3513
if (uniffi_ed_agent_ffi_checksum_constructor_ffiedagent_new() != 19584) {
3514
return InitializationResult.apiChecksumMismatch
3515
}
3516
+ if (uniffi_ed_agent_ffi_checksum_constructor_ffiedagent_new_with_app_id() != 58709) {
3517
+ return InitializationResult.apiChecksumMismatch
3518
+ }
3519
if (uniffi_ed_agent_ffi_checksum_method_ffiapprovalhandler_approve() != 9982) {
3520
return InitializationResult.apiChecksumMismatch
3521
}
3522
if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_status_changed() != 50680) {
3523
return InitializationResult.apiChecksumMismatch
3524
}
3175
- if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_tool_requested() != 58928) {
3525
+ if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_text_delta() != 35660) {
3526
+ return InitializationResult.apiChecksumMismatch
3527
+ }
3528
+ if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_tool_requested() != 9203) {
3529
+ return InitializationResult.apiChecksumMismatch
3530
+ }
3531
+ if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_approval_requested() != 14469) {
3532
return InitializationResult.apiChecksumMismatch
3533
}
3178
- if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_approval_requested() != 13081) {
3534
+ if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_tool_started() != 36088) {
3535
return InitializationResult.apiChecksumMismatch
3536
}
3181
- if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_tool_started() != 23754) {
3537
+ if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_tool_finished() != 51284) {
3538
return InitializationResult.apiChecksumMismatch
3539
}
3184
- if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_tool_finished() != 4350) {
3540
+ if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_agent_replied() != 55514) {
3541
return InitializationResult.apiChecksumMismatch
3542
}
3187
- if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_agent_replied() != 33842) {
3543
+ if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_warning() != 17877) {
3544
return InitializationResult.apiChecksumMismatch
3545
}
3190
- if (uniffi_ed_agent_ffi_checksum_method_ffieventlistener_warning() != 43170) {
3546
+ if (uniffi_ed_agent_ffi_checksum_method_ffistreamlistener_on_delta() != 13726) {
3547
return InitializationResult.apiChecksumMismatch
3548
}
3549
if (uniffi_ed_agent_ffi_checksum_method_ffitoolexecutor_execute() != 36465) {
@@ -3196,6 +3552,7 @@ private let initializationResult: InitializationResult = {
3552
3553
uniffiCallbackInitFfiApprovalHandler()
3554
uniffiCallbackInitFfiEventListener()
3555
+ uniffiCallbackInitFfiStreamListener()
3556
uniffiCallbackInitFfiToolExecutor()
3557
return InitializationResult.ok
3558
}()
Tests/EdTests/FfiRoundTripTests.swift
+50
@@ -12,3 +12,53 @@ import Testing
12
#expect(info.modelName == nil)
13
#expect(info.historyLength == 0)
14
}
15
+
16
+@Test func agentAcceptsAnAppIDAndStartsEmpty() async {
17
+ let agent = EdAgent(appID: "test-app")
18
+ #expect(await agent.history().isEmpty)
19
+}
20
+
21
+// These need a loaded model to do anything, so without one they must be
22
+// harmless rather than crash across the FFI boundary.
23
+@Test func settersAndRestoreAreNoOpsWithoutAModel() async {
24
+ let agent = EdAgent(appID: nil)
25
+ await agent.setSystemPrompt("You are terse.")
26
+ await agent.clearSystemPrompt()
27
+ await agent.setSampling(EdSamplingConfiguration(temperature: 0.2, maxTokens: 64))
28
+ await agent.restoreHistory([
29
+ EdChatMessage(role: .user, content: "hi"),
30
+ EdChatMessage(role: .assistant, content: "hello"),
31
+ ])
32
+ #expect(await agent.history().isEmpty)
33
+}
34
+
35
+@Test func generateAndStreamFailCleanlyWithoutAModel() async {
36
+ let agent = EdAgent()
37
+
38
+ await #expect(throws: (any Error).self) {
39
+ _ = try await agent.generate(messages: [EdChatMessage(role: .user, content: "hi")])
40
+ }
41
+
42
+ var deltas: [String] = []
43
+ var failure: (any Error)?
44
+ do {
45
+ for try await delta in agent.stream("hi") { deltas.append(delta) }
46
+ } catch {
47
+ failure = error
48
+ }
49
+ #expect(deltas.isEmpty)
50
+ #expect(failure != nil)
51
+}
52
+
53
+@Test func eventsStreamReportsStatusChanges() async {
54
+ let agent = EdAgent()
55
+ var events = agent.events().makeAsyncIterator()
56
+
57
+ await agent.unload()
58
+
59
+ guard case .statusChanged(let status, _, _)? = await events.next() else {
60
+ Issue.record("expected a status change")
61
+ return
62
+ }
63
+ #expect(status == .unloaded)
64
+}