Skip to main content

rig_agent/agent/
model.rs

1//! Runtime model handles for the concrete agent facade.
2//!
3//! Provider authors implement [`CompletionModel`] as usual. [`ModelHandle`]
4//! erases that implementation once, when it enters the high-level agent
5//! runtime, so an [`Agent`](super::Agent) can replace or route models without
6//! changing its Rust type. Because completion responses are already normalized
7//! at the provider boundary, the erasure is lossless: a handle is itself a
8//! [`CompletionModel`] with the same unary and streaming behavior.
9//!
10//! [`CompletionModel::capabilities`] is captured **by value** at erasure time;
11//! the handle never calls back into the provider for capability checks.
12
13use std::{fmt, sync::Arc};
14
15use rig_core::{
16    completion::{
17        CompletionError, CompletionModel, CompletionRequest, CompletionResponse,
18        ProviderCapabilities,
19    },
20    streaming::StreamingCompletionResponse,
21    wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync},
22};
23
24/// Private object-safe mirror of [`CompletionModel`], the same shape
25/// `tower::BoxService` uses: the public trait stays generic (RPITIT futures),
26/// this dyn-safe twin exists only so [`ModelHandle`] can store one vtable.
27///
28/// The `WasmCompat*` supertraits carry the cfg fork (no-op markers on browser
29/// wasm), mirroring `ErasedTool` in `crate::tool`. Capabilities are
30/// deliberately absent: they are construction-time data captured alongside the
31/// erased model, not behavior to call back into.
32trait ErasedModel: WasmCompatSend + WasmCompatSync {
33    fn completion(
34        &self,
35        request: CompletionRequest,
36    ) -> WasmBoxedFuture<'_, Result<CompletionResponse, CompletionError>>;
37
38    fn stream(
39        &self,
40        request: CompletionRequest,
41    ) -> WasmBoxedFuture<'_, Result<StreamingCompletionResponse, CompletionError>>;
42}
43
44/// Every completion model erases; the borrowed futures delegate straight to
45/// the RPITIT methods, so erasure adds one `Box::pin` per attempt and never
46/// clones the model.
47impl<M> ErasedModel for M
48where
49    M: CompletionModel + 'static,
50{
51    fn completion(
52        &self,
53        request: CompletionRequest,
54    ) -> WasmBoxedFuture<'_, Result<CompletionResponse, CompletionError>> {
55        Box::pin(CompletionModel::completion(self, request))
56    }
57
58    fn stream(
59        &self,
60        request: CompletionRequest,
61    ) -> WasmBoxedFuture<'_, Result<StreamingCompletionResponse, CompletionError>> {
62        Box::pin(CompletionModel::stream(self, request))
63    }
64}
65
66/// The handle's single allocation: snapshot data first, the unsized erased
67/// model last, so `Arc<ModelDriver<M>>` unsize-coerces to
68/// `Arc<ModelDriver<dyn ErasedModel>>` without a second box.
69struct ModelDriver<M: ?Sized> {
70    /// Capability snapshot taken at erasure time (see [`ProviderCapabilities`]).
71    capabilities: ProviderCapabilities,
72    label: Option<String>,
73    model: M,
74}
75
76/// A cloneable, opaque handle to live completion-model behavior.
77///
78/// The handle is the boundary between typed provider authoring and Rig's
79/// concrete high-level agent facade. It is intentionally not serializable:
80/// captured clients, credentials, and transports are live process state.
81/// Applications that need persistent model selection should serialize a
82/// separate identifier and resolve it to a handle at runtime.
83///
84/// Cloning is cheap and shares the retained model through an [`Arc`]. Replacing
85/// a handle on one cloned agent has value semantics and does not mutate other
86/// agent clones; each in-flight attempt owns its own handle clone, so in-flight
87/// work never rebinds. The erased model is retained in a shared [`Arc`], so
88/// each completion/stream attempt runs against the same instance: no per-call
89/// clone of the model itself, and interior-mutable model state (counters,
90/// rotating endpoints, local caches) persists across attempts.
91///
92/// The absence of serde implementations is intentional:
93///
94/// ```compile_fail
95/// use rig_agent::ModelHandle;
96///
97/// fn requires_serialize<T: serde::Serialize>() {}
98/// requires_serialize::<ModelHandle>();
99/// ```
100///
101/// ```compile_fail
102/// use rig_agent::ModelHandle;
103///
104/// fn requires_deserialize<T: for<'de> serde::Deserialize<'de>>() {}
105/// requires_deserialize::<ModelHandle>();
106/// ```
107#[derive(Clone)]
108pub struct ModelHandle {
109    inner: Arc<ModelDriver<dyn ErasedModel>>,
110}
111
112impl ModelHandle {
113    /// Erase a typed completion model into a runtime model handle.
114    pub fn new<M>(model: M) -> Self
115    where
116        M: CompletionModel + 'static,
117    {
118        Self::from_parts(None, model)
119    }
120
121    /// Erase a typed completion model and attach a diagnostic label.
122    ///
123    /// Labels are for logs and routing diagnostics only. They are not stable
124    /// provider identities and are not serialized.
125    pub fn named<M>(label: impl Into<String>, model: M) -> Self
126    where
127        M: CompletionModel + 'static,
128    {
129        Self::from_parts(Some(label.into()), model)
130    }
131
132    fn from_parts<M>(label: Option<String>, model: M) -> Self
133    where
134        M: CompletionModel + 'static,
135    {
136        // Capture the capability snapshot once, at erasure time; the model is
137        // consumed by value and never cloned again (pinned by the
138        // `erasure_never_clones_the_model` test below).
139        let capabilities = model.capabilities();
140        Self {
141            inner: Arc::new(ModelDriver {
142                capabilities,
143                label,
144                model,
145            }),
146        }
147    }
148
149    /// Returns the optional diagnostic label attached to this handle.
150    pub fn label(&self) -> Option<&str> {
151        self.inner.label.as_deref()
152    }
153}
154
155/// A handle behaves exactly like the model it erased, with capabilities served
156/// from the snapshot captured at erasure time.
157///
158/// It deliberately adds no request validation of its own. Both agent surfaces
159/// reach a model through `CompletionRequestBuilder` — `runner.rs`'s blocking
160/// turn calls `builder.send()`, the streaming turn calls `builder.stream()` —
161/// and the builder already runs
162/// [`CompletionRequest::validate_message_content`]. Repeating it here would
163/// scan the whole history a second time on every model call and buy nothing.
164impl CompletionModel for ModelHandle {
165    fn completion(
166        &self,
167        request: CompletionRequest,
168    ) -> impl Future<Output = Result<CompletionResponse, CompletionError>>
169    + rig_core::wasm_compat::WasmCompatSend {
170        self.inner.model.completion(request)
171    }
172
173    fn stream(
174        &self,
175        request: CompletionRequest,
176    ) -> impl Future<Output = Result<StreamingCompletionResponse, CompletionError>>
177    + rig_core::wasm_compat::WasmCompatSend {
178        self.inner.model.stream(request)
179    }
180
181    fn capabilities(&self) -> ProviderCapabilities {
182        self.inner.capabilities
183    }
184}
185
186impl fmt::Debug for ModelHandle {
187    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
188        formatter
189            .debug_struct("ModelHandle")
190            .field("label", &self.label())
191            .field("capabilities", &self.inner.capabilities)
192            .finish_non_exhaustive()
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use std::sync::atomic::{AtomicUsize, Ordering};
199
200    use super::*;
201    use crate::test_utils::{MockCompletionModel, MockTurn};
202
203    /// Wraps the mock model and counts every `Clone` of itself.
204    struct CloneCountingModel {
205        inner: MockCompletionModel,
206        clones: Arc<AtomicUsize>,
207    }
208
209    impl Clone for CloneCountingModel {
210        fn clone(&self) -> Self {
211            self.clones.fetch_add(1, Ordering::SeqCst);
212            Self {
213                inner: self.inner.clone(),
214                clones: Arc::clone(&self.clones),
215            }
216        }
217    }
218
219    impl CompletionModel for CloneCountingModel {
220        fn completion(
221            &self,
222            request: CompletionRequest,
223        ) -> impl Future<Output = Result<CompletionResponse, CompletionError>>
224        + rig_core::wasm_compat::WasmCompatSend {
225            CompletionModel::completion(&self.inner, request)
226        }
227
228        fn stream(
229            &self,
230            request: CompletionRequest,
231        ) -> impl Future<Output = Result<StreamingCompletionResponse, CompletionError>>
232        + rig_core::wasm_compat::WasmCompatSend {
233            CompletionModel::stream(&self.inner, request)
234        }
235    }
236
237    /// Erasure consumes the model by value: no code path may ever clone it,
238    /// no matter how many attempts run through the handle. This pins the
239    /// shared-instance semantics structurally, not just in prose.
240    #[tokio::test]
241    async fn erasure_never_clones_the_model() {
242        let clones = Arc::new(AtomicUsize::new(0));
243        let model = CloneCountingModel {
244            inner: MockCompletionModel::from_turns([
245                MockTurn::text("one"),
246                MockTurn::text("two"),
247                MockTurn::text("three"),
248            ]),
249            clones: Arc::clone(&clones),
250        };
251
252        let handle = ModelHandle::new(model);
253        let request = handle.completion_request("go").build();
254        CompletionModel::completion(&handle, request.clone())
255            .await
256            .expect("first scripted turn");
257        CompletionModel::completion(&handle, request.clone())
258            .await
259            .expect("second scripted turn");
260        CompletionModel::completion(&handle, request)
261            .await
262            .expect("third scripted turn");
263
264        let stream_clones = Arc::new(AtomicUsize::new(0));
265        let stream_model = CloneCountingModel {
266            inner: MockCompletionModel::from_stream_turns([
267                vec![
268                    crate::test_utils::MockStreamEvent::text("a"),
269                    crate::test_utils::MockStreamEvent::final_response_with_default_usage(),
270                ],
271                vec![
272                    crate::test_utils::MockStreamEvent::text("b"),
273                    crate::test_utils::MockStreamEvent::final_response_with_default_usage(),
274                ],
275            ]),
276            clones: Arc::clone(&stream_clones),
277        };
278        let stream_handle = ModelHandle::new(stream_model);
279        let stream_request = stream_handle.completion_request("go").build();
280        CompletionModel::stream(&stream_handle, stream_request.clone())
281            .await
282            .expect("first scripted stream turn");
283        CompletionModel::stream(&stream_handle, stream_request)
284            .await
285            .expect("second scripted stream turn");
286
287        assert_eq!(
288            clones.load(Ordering::SeqCst),
289            0,
290            "erasure and unary attempts must never clone the model"
291        );
292        assert_eq!(
293            stream_clones.load(Ordering::SeqCst),
294            0,
295            "erasure and streaming attempts must never clone the model"
296        );
297    }
298
299    /// A model without any `Clone` impl at all must pass through every public
300    /// erasure seam. The assertions are the bounds themselves — a regression
301    /// is a compile error, which is the strongest form this check can take.
302    struct NonCloneModel;
303
304    impl CompletionModel for NonCloneModel {
305        fn completion(
306            &self,
307            _request: CompletionRequest,
308        ) -> impl Future<Output = Result<CompletionResponse, CompletionError>>
309        + rig_core::wasm_compat::WasmCompatSend {
310            std::future::ready(Err(CompletionError::ProviderError(
311                "compile-time probe".to_string(),
312            )))
313        }
314
315        fn stream(
316            &self,
317            _request: CompletionRequest,
318        ) -> impl Future<Output = Result<StreamingCompletionResponse, CompletionError>>
319        + rig_core::wasm_compat::WasmCompatSend {
320            std::future::ready(Err(CompletionError::ProviderError(
321                "compile-time probe".to_string(),
322            )))
323        }
324    }
325
326    #[test]
327    fn traits() {
328        fn assert_completion_model<M: CompletionModel>() {}
329
330        assert_completion_model::<NonCloneModel>();
331        // `Arc<M>` forwards the trait, so the documented "wrap it in an `Arc`
332        // if needed" guidance holds for non-`Clone` models through the
333        // generic builder path (`completion_request` gates on `Self: Clone`,
334        // which `Arc<M>` always satisfies).
335        assert_completion_model::<std::sync::Arc<NonCloneModel>>();
336
337        // Construction through the public erasure seams type-checks without a
338        // `Clone` impl; never awaited — the bounds are the test.
339        let _ = || {
340            let handle = ModelHandle::new(NonCloneModel);
341            let named = ModelHandle::named("probe", NonCloneModel);
342            let via_arc = std::sync::Arc::new(NonCloneModel).completion_request("go");
343            let builder = crate::AgentBuilder::new(NonCloneModel);
344            (handle, named, via_arc, builder)
345        };
346    }
347}