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 +}