Skip to main content

af_agent/
model.rs

1//! The chat-model abstraction the agent loop runs against.
2//!
3//! Decoupling the loop from a concrete client means: (a) the loop is unit-
4//! testable with a scripted mock — no network; (b) any backend (the real
5//! [`LlmClient`], a local model, a replay harness) plugs in by implementing one
6//! method.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::sync::Arc;
10
11use af_llm::{CompletionRequest, CompletionResponse, LlmClient, LlmError};
12use async_trait::async_trait;
13use tokio::sync::mpsc::UnboundedSender;
14
15/// Registered model and the request options it supports.
16#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct ModelDescriptor {
18    /// Stable identifier of this record.
19    pub id: String,
20    /// Name of the provider that produced or serves this record.
21    pub provider: String,
22    /// Reasoning efforts the provider accepts (`low`, `medium`, `high`).
23    #[serde(default)]
24    pub reasoning_efforts: BTreeSet<String>,
25    /// Input modalities the model accepts (for example `text`, `image`).
26    #[serde(default)]
27    pub input_modalities: BTreeSet<String>,
28}
29
30/// Model registration or resolution failure.
31#[derive(Debug, thiserror::Error, PartialEq, Eq)]
32pub enum ModelRegistryError {
33    /// Model descriptor requires id and provider.
34    #[error("model descriptor requires id and provider")]
35    InvalidDescriptor,
36    /// Model is already registered.
37    #[error("model is already registered: {0}")]
38    Duplicate(String),
39    /// Model is not registered.
40    #[error("model is not registered: {0}")]
41    Unknown(String),
42    /// Preferred model is not allowed.
43    #[error("preferred model is not allowed: {0}")]
44    NotAllowed(String),
45}
46
47/// Exact-id registry of models a Profile may select.
48#[derive(Default)]
49pub struct ModelRegistry {
50    models: BTreeMap<String, (ModelDescriptor, Arc<dyn ChatModel>)>,
51}
52
53impl ModelRegistry {
54    /// Register a model; duplicate ids are rejected.
55    pub fn register(
56        &mut self,
57        descriptor: ModelDescriptor,
58        model: Arc<dyn ChatModel>,
59    ) -> Result<(), ModelRegistryError> {
60        if descriptor.id.trim().is_empty() || descriptor.provider.trim().is_empty() {
61            return Err(ModelRegistryError::InvalidDescriptor);
62        }
63        if self.models.contains_key(&descriptor.id) {
64            return Err(ModelRegistryError::Duplicate(descriptor.id));
65        }
66        self.models
67            .insert(descriptor.id.clone(), (descriptor, model));
68        Ok(())
69    }
70
71    /// Registered descriptors, sorted by id.
72    pub fn descriptors(&self) -> impl Iterator<Item = &ModelDescriptor> {
73        self.models.values().map(|(descriptor, _)| descriptor)
74    }
75
76    /// Resolve an exact model id within `allowed`, validating the requested reasoning effort.
77    pub fn resolve(
78        &self,
79        preferred: &str,
80        allowed: &BTreeSet<String>,
81    ) -> Result<Arc<dyn ChatModel>, ModelRegistryError> {
82        if !allowed.contains(preferred) {
83            return Err(ModelRegistryError::NotAllowed(preferred.into()));
84        }
85        self.models
86            .get(preferred)
87            .map(|(descriptor, model)| {
88                Arc::new(RegisteredModel {
89                    model: Arc::clone(model),
90                    images: descriptor.input_modalities.contains("image"),
91                    provider: descriptor.provider.clone(),
92                }) as Arc<dyn ChatModel>
93            })
94            .ok_or_else(|| ModelRegistryError::Unknown(preferred.into()))
95    }
96}
97
98struct RegisteredModel {
99    model: Arc<dyn ChatModel>,
100    images: bool,
101    provider: String,
102}
103
104#[async_trait]
105impl ChatModel for RegisteredModel {
106    fn provider_name(&self) -> Option<&str> {
107        Some(&self.provider)
108    }
109    fn capabilities(&self) -> ProviderCapabilities {
110        self.model.capabilities()
111    }
112    async fn complete_streaming(
113        &self,
114        request: &CompletionRequest,
115        delta_tx: UnboundedSender<(String, bool)>,
116    ) -> Result<CompletionResponse, LlmError> {
117        if !self.images
118            && request
119                .messages
120                .iter()
121                .any(|message| !message.images.is_empty())
122        {
123            return Err(LlmError::InvalidInput(
124                "selected model does not support images".into(),
125            ));
126        }
127        self.model.complete_streaming(request, delta_tx).await
128    }
129}
130
131/// Context-runtime features a provider can carry. Every switch defaults to
132/// enabled; an adapter turns one off when its provider cannot honour it, and a
133/// host may override the whole set on the runtime.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
135pub struct ProviderCapabilities {
136    /// Advertise a bounded initial tool set and load the rest on demand.
137    pub deferred_tools: bool,
138    /// A direct call to a deferred tool loads it instead of failing the call.
139    pub direct_tool_activation: bool,
140    /// Tier 1 compaction may replace old tool results with bounded placeholders.
141    pub tool_result_condensation: bool,
142    /// Tier 2 compaction asks for a structured JSON checkpoint instead of prose.
143    pub structured_checkpoint: bool,
144    /// Tool results from earlier Runs enter a new Run as placeholders.
145    pub cross_run_tool_placeholders: bool,
146}
147
148impl ProviderCapabilities {
149    /// Every switch enabled.
150    pub const fn all() -> Self {
151        Self {
152            deferred_tools: true,
153            direct_tool_activation: true,
154            tool_result_condensation: true,
155            structured_checkpoint: true,
156            cross_run_tool_placeholders: true,
157        }
158    }
159
160    /// Every switch disabled: the runtime behaves as a plain transcript loop.
161    pub const fn none() -> Self {
162        Self {
163            deferred_tools: false,
164            direct_tool_activation: false,
165            tool_result_condensation: false,
166            structured_checkpoint: false,
167            cross_run_tool_placeholders: false,
168        }
169    }
170}
171
172impl Default for ProviderCapabilities {
173    fn default() -> Self {
174        Self::all()
175    }
176}
177
178/// Anything that can turn a chat-completion request into a response.
179#[async_trait]
180pub trait ChatModel: Send + Sync {
181    /// Stable provider attribution; custom adapters may leave this unknown.
182    fn provider_name(&self) -> Option<&str> {
183        None
184    }
185    /// Context-runtime switches this provider supports.
186    fn capabilities(&self) -> ProviderCapabilities {
187        ProviderCapabilities::default()
188    }
189    /// Stream cumulative `(content, has_tool_calls)` updates and return the
190    /// canonical terminal response. This is the only model invocation path.
191    async fn complete_streaming(
192        &self,
193        request: &CompletionRequest,
194        delta_tx: UnboundedSender<(String, bool)>,
195    ) -> Result<CompletionResponse, LlmError>;
196}
197
198/// The production model is the real LLM client.
199#[async_trait]
200impl ChatModel for LlmClient {
201    async fn complete_streaming(
202        &self,
203        request: &CompletionRequest,
204        delta_tx: UnboundedSender<(String, bool)>,
205    ) -> Result<CompletionResponse, LlmError> {
206        self.complete_stream_single_attempt(request, |content, has_tools| {
207            let _ = delta_tx.send((content.to_string(), has_tools));
208        })
209        .await
210    }
211}
212
213/// A type-erased model, so callers (e.g. an HTTP service holding one `Agent` in
214/// shared state) can pick the backend at runtime — real client vs stub vs
215/// replay — without the loop being generic over every concrete type.
216#[async_trait]
217impl ChatModel for Arc<dyn ChatModel> {
218    fn provider_name(&self) -> Option<&str> {
219        (**self).provider_name()
220    }
221    fn capabilities(&self) -> ProviderCapabilities {
222        (**self).capabilities()
223    }
224    async fn complete_streaming(
225        &self,
226        request: &CompletionRequest,
227        delta_tx: UnboundedSender<(String, bool)>,
228    ) -> Result<CompletionResponse, LlmError> {
229        (**self).complete_streaming(request, delta_tx).await
230    }
231}
232
233#[cfg(test)]
234mod registry_tests {
235    use super::*;
236    use af_llm::{ChatMessage, Choice, CompletionResponse, LlmError};
237
238    struct Fixed;
239
240    #[async_trait]
241    impl ChatModel for Fixed {
242        async fn complete_streaming(
243            &self,
244            _: &CompletionRequest,
245            _: UnboundedSender<(String, bool)>,
246        ) -> Result<CompletionResponse, LlmError> {
247            Ok(CompletionResponse {
248                id: "fixed".into(),
249                choices: vec![Choice {
250                    index: 0,
251                    message: ChatMessage::assistant("ok"),
252                    output_blocks: Vec::new(),
253                    finish_reason: None,
254                }],
255                usage: None,
256            })
257        }
258    }
259
260    fn descriptor(id: &str) -> ModelDescriptor {
261        ModelDescriptor {
262            id: id.into(),
263            provider: "test".into(),
264            reasoning_efforts: BTreeSet::from(["low".into()]),
265            input_modalities: BTreeSet::from(["text".into()]),
266        }
267    }
268
269    #[tokio::test]
270    async fn registered_modality_is_enforced_before_invocation() {
271        let mut registry = ModelRegistry::default();
272        registry
273            .register(descriptor("text"), Arc::new(Fixed))
274            .unwrap();
275        let mut vision = descriptor("vision");
276        vision.input_modalities.insert("image".into());
277        registry.register(vision, Arc::new(Fixed)).unwrap();
278        let mut message = ChatMessage::user("describe");
279        message.images.push(af_llm::InputImage {
280            asset_id: "asset".parse().unwrap(),
281            media_type: "image/png".into(),
282        });
283        for (id, success) in [("text", false), ("vision", true)] {
284            let model = registry.resolve(id, &BTreeSet::from([id.into()])).unwrap();
285            let (tx, _) = tokio::sync::mpsc::unbounded_channel();
286            let result = model
287                .complete_streaming(&CompletionRequest::new(id, vec![message.clone()]), tx)
288                .await;
289            assert_eq!(result.is_ok(), success);
290        }
291    }
292
293    #[test]
294    fn registry_is_exact_sorted_and_rejects_duplicates() {
295        let mut registry = ModelRegistry::default();
296        registry.register(descriptor("z"), Arc::new(Fixed)).unwrap();
297        registry.register(descriptor("a"), Arc::new(Fixed)).unwrap();
298        assert_eq!(
299            registry
300                .descriptors()
301                .map(|value| value.id.as_str())
302                .collect::<Vec<_>>(),
303            vec!["a", "z"]
304        );
305        assert_eq!(
306            registry
307                .register(descriptor("a"), Arc::new(Fixed))
308                .unwrap_err(),
309            ModelRegistryError::Duplicate("a".into())
310        );
311        assert!(registry.resolve("a", &BTreeSet::from(["a".into()])).is_ok());
312        assert!(matches!(
313            registry.resolve("z", &BTreeSet::from(["a".into()])),
314            Err(ModelRegistryError::NotAllowed(value)) if value == "z"
315        ));
316    }
317}