Skip to main content

behest_context/
factory.rs

1//! High-level context factory API.
2//!
3//! This module was moved here from `behest::context` so the context
4//! composition logic lives in the [`behest_context`] crate next to the
5//! lower-level layered trait system.
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use behest_core::error::ContextError;
14use behest_provider::{ChatRequest, Message, ModelName, ToolChoice, ToolSpec};
15
16/// Result type for context operations.
17pub type ContextResult<T> = std::result::Result<T, ContextError>;
18
19/// Input provided to context adapters.
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct ContextInput {
22    /// Optional user message to include.
23    pub user_message: Option<String>,
24    /// Optional session identifier.
25    pub session_id: Option<String>,
26    /// Application-specific metadata.
27    pub metadata: Value,
28}
29
30impl ContextInput {
31    /// Creates an empty context input.
32    #[must_use]
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    /// Sets the user message.
38    #[must_use]
39    pub fn with_user_message(mut self, message: impl Into<String>) -> Self {
40        self.user_message = Some(message.into());
41        self
42    }
43
44    /// Sets the session identifier.
45    #[must_use]
46    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
47        self.session_id = Some(session_id.into());
48        self
49    }
50
51    /// Sets application metadata.
52    #[must_use]
53    pub fn with_metadata(mut self, metadata: Value) -> Self {
54        self.metadata = metadata;
55        self
56    }
57}
58
59/// Output produced by context construction.
60#[derive(Debug, Clone, Default)]
61pub struct ContextOutput {
62    messages: Vec<Message>,
63}
64
65impl ContextOutput {
66    /// Creates an empty context output.
67    #[must_use]
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Creates a context output from messages.
73    #[must_use]
74    pub fn from_messages(messages: Vec<Message>) -> Self {
75        Self { messages }
76    }
77
78    /// Returns the composed messages.
79    #[must_use]
80    pub fn messages(&self) -> &[Message] {
81        &self.messages
82    }
83
84    /// Returns a mutable reference to the composed messages.
85    #[must_use]
86    pub fn messages_mut(&mut self) -> &mut Vec<Message> {
87        &mut self.messages
88    }
89
90    /// Consumes the output and returns the messages.
91    #[must_use]
92    pub fn into_messages(self) -> Vec<Message> {
93        self.messages
94    }
95
96    /// Appends messages to the output.
97    pub fn extend(&mut self, messages: impl IntoIterator<Item = Message>) {
98        self.messages.extend(messages);
99    }
100
101    /// Builds a [`ChatRequest`] from this context output.
102    #[must_use]
103    pub fn into_request(self, model: ModelName) -> ChatRequest {
104        ChatRequest {
105            model,
106            messages: self.messages,
107            tools: Vec::new(),
108            tool_choice: ToolChoice::default(),
109            response_format: None,
110            temperature: None,
111            top_p: None,
112            max_output_tokens: None,
113            stop: Vec::new(),
114            metadata: Value::Null,
115        }
116    }
117
118    /// Builds a [`ChatRequest`] with tool definitions from a registry.
119    #[must_use]
120    pub fn into_request_with_tools(self, model: ModelName, tools: &[ToolSpec]) -> ChatRequest {
121        ChatRequest {
122            model,
123            messages: self.messages,
124            tools: tools.to_vec(),
125            tool_choice: ToolChoice::default(),
126            response_format: None,
127            temperature: None,
128            top_p: None,
129            max_output_tokens: None,
130            stop: Vec::new(),
131            metadata: Value::Null,
132        }
133    }
134}
135
136/// Pluggable context source that produces message fragments.
137#[async_trait]
138pub trait ContextAdapter: Send + Sync {
139    /// Returns the adapter name.
140    fn name(&self) -> &str;
141
142    /// Produces message fragments for the given input.
143    async fn produce(&self, input: &ContextInput) -> ContextResult<Vec<Message>>;
144}
145
146/// Static context adapter that returns fixed messages.
147pub struct StaticAdapter {
148    name: String,
149    messages: Vec<Message>,
150}
151
152impl StaticAdapter {
153    /// Creates a static adapter with a system message.
154    #[must_use]
155    pub fn system(text: impl Into<String>) -> Self {
156        Self {
157            name: "system".to_owned(),
158            messages: vec![Message::system_text(text)],
159        }
160    }
161
162    /// Creates a static adapter with a user message.
163    #[must_use]
164    pub fn user(text: impl Into<String>) -> Self {
165        Self {
166            name: "user".to_owned(),
167            messages: vec![Message::user_text(text)],
168        }
169    }
170
171    /// Creates a static adapter with custom messages.
172    #[must_use]
173    pub fn messages(name: impl Into<String>, messages: Vec<Message>) -> Self {
174        Self {
175            name: name.into(),
176            messages,
177        }
178    }
179}
180
181#[async_trait]
182impl ContextAdapter for StaticAdapter {
183    fn name(&self) -> &str {
184        &self.name
185    }
186
187    async fn produce(&self, _input: &ContextInput) -> ContextResult<Vec<Message>> {
188        Ok(self.messages.clone())
189    }
190}
191
192/// Function-based context adapter.
193pub struct FunctionAdapter<F> {
194    name: String,
195    handler: F,
196}
197
198impl<F, Fut> FunctionAdapter<F>
199where
200    F: Fn(ContextInput) -> Fut + Send + Sync + 'static,
201    Fut: std::future::Future<Output = ContextResult<Vec<Message>>> + Send + 'static,
202{
203    /// Creates a function-based context adapter.
204    #[must_use]
205    pub fn new(name: impl Into<String>, handler: F) -> Self {
206        Self {
207            name: name.into(),
208            handler,
209        }
210    }
211}
212
213#[async_trait]
214impl<F, Fut> ContextAdapter for FunctionAdapter<F>
215where
216    F: Fn(ContextInput) -> Fut + Send + Sync + 'static,
217    Fut: std::future::Future<Output = ContextResult<Vec<Message>>> + Send + 'static,
218{
219    fn name(&self) -> &str {
220        &self.name
221    }
222
223    async fn produce(&self, input: &ContextInput) -> ContextResult<Vec<Message>> {
224        (self.handler)(input.clone()).await
225    }
226}
227
228/// Multi-adapter context factory.
229///
230/// The factory maintains an ordered list of context adapters and composes
231/// their output into a single [`ContextOutput`].
232#[derive(Clone, Default)]
233pub struct ContextFactory {
234    adapters: Vec<Arc<dyn ContextAdapter>>,
235}
236
237impl ContextFactory {
238    /// Creates an empty context factory.
239    #[must_use]
240    pub fn new() -> Self {
241        Self::default()
242    }
243
244    /// Registers a context adapter.
245    pub fn register<A>(&mut self, adapter: A)
246    where
247        A: ContextAdapter + 'static,
248    {
249        self.adapters.push(Arc::new(adapter));
250    }
251
252    /// Registers an already shared context adapter.
253    pub fn register_arc(&mut self, adapter: Arc<dyn ContextAdapter>) {
254        self.adapters.push(adapter);
255    }
256
257    /// Returns the number of registered adapters.
258    #[must_use]
259    pub fn len(&self) -> usize {
260        self.adapters.len()
261    }
262
263    /// Returns `true` when no adapters are registered.
264    #[must_use]
265    pub fn is_empty(&self) -> bool {
266        self.adapters.is_empty()
267    }
268
269    /// Returns adapter names in registration order.
270    pub fn adapter_names(&self) -> impl Iterator<Item = &str> {
271        self.adapters.iter().map(|a| a.name())
272    }
273
274    /// Builds context output by invoking all adapters in order.
275    ///
276    /// # Errors
277    ///
278    /// Returns [`ContextError::AdapterFailed`] when any adapter fails.
279    pub async fn build(&self, input: &ContextInput) -> ContextResult<ContextOutput> {
280        let mut output = ContextOutput::new();
281
282        for adapter in &self.adapters {
283            let messages =
284                adapter
285                    .produce(input)
286                    .await
287                    .map_err(|e| ContextError::AdapterFailed {
288                        adapter: adapter.name().to_owned(),
289                        message: e.to_string(),
290                    })?;
291            output.extend(messages);
292        }
293
294        Ok(output)
295    }
296
297    /// Builds a [`ChatRequest`] with context and optional tool specs.
298    ///
299    /// Pass the tool specs slice (e.g. from
300    /// `behest_tool::ToolRegistry::specs`) instead of a typed registry
301    /// reference to keep `behest-context` free of a `behest-tool` dependency.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`ContextError::AdapterFailed`] when any adapter fails.
306    pub async fn build_request(
307        &self,
308        input: &ContextInput,
309        model: ModelName,
310        tool_specs: Option<&[ToolSpec]>,
311    ) -> ContextResult<ChatRequest> {
312        let output = self.build(input).await?;
313
314        let request = if let Some(specs) = tool_specs {
315            output.into_request_with_tools(model, specs)
316        } else {
317            output.into_request(model)
318        };
319
320        Ok(request)
321    }
322}
323
324#[cfg(test)]
325#[allow(clippy::unwrap_used)]
326mod tests {
327    use super::*;
328    use serde_json::json;
329
330    #[test]
331    fn context_input_should_support_builder_pattern() {
332        let input = ContextInput::new()
333            .with_user_message("Hello")
334            .with_session_id("session_123")
335            .with_metadata(json!({"key": "value"}));
336
337        assert_eq!(input.user_message, Some("Hello".to_owned()));
338        assert_eq!(input.session_id, Some("session_123".to_owned()));
339        assert_eq!(input.metadata, json!({"key": "value"}));
340    }
341
342    #[test]
343    fn context_output_should_be_empty_when_new() {
344        let output = ContextOutput::new();
345        assert!(output.messages().is_empty());
346    }
347
348    #[test]
349    fn context_output_should_extend_messages() {
350        let mut output = ContextOutput::new();
351        output.extend(vec![
352            Message::system_text("System"),
353            Message::user_text("User"),
354        ]);
355
356        assert_eq!(output.messages().len(), 2);
357    }
358
359    #[test]
360    fn context_output_should_convert_to_request() {
361        let output = ContextOutput::from_messages(vec![
362            Message::system_text("System"),
363            Message::user_text("User"),
364        ]);
365
366        let request = output.into_request(ModelName::new("gpt-4"));
367
368        assert_eq!(request.model.as_str(), "gpt-4");
369        assert_eq!(request.messages.len(), 2);
370        assert!(request.tools.is_empty());
371    }
372
373    #[test]
374    fn context_output_should_convert_to_request_with_tools() {
375        let output = ContextOutput::from_messages(vec![Message::user_text("Hello")]);
376        let tools = vec![ToolSpec::new("echo", "Echo tool", json!({}))];
377
378        let request = output.into_request_with_tools(ModelName::new("gpt-4"), &tools);
379
380        assert_eq!(request.tools.len(), 1);
381        assert_eq!(request.tools[0].name, "echo");
382    }
383
384    #[test]
385    fn context_factory_should_be_empty_when_new() {
386        let factory = ContextFactory::new();
387        assert!(factory.is_empty());
388        assert_eq!(factory.len(), 0);
389    }
390
391    #[test]
392    fn context_factory_should_register_adapters() {
393        let mut factory = ContextFactory::new();
394        factory.register(StaticAdapter::system("System prompt"));
395        factory.register(StaticAdapter::user("User message"));
396
397        assert_eq!(factory.len(), 2);
398    }
399
400    #[test]
401    fn context_factory_should_list_adapter_names() {
402        let mut factory = ContextFactory::new();
403        factory.register(StaticAdapter::system("System"));
404        factory.register(StaticAdapter::user("User"));
405
406        let names: Vec<&str> = factory.adapter_names().collect();
407        assert_eq!(names, vec!["system", "user"]);
408    }
409
410    #[tokio::test]
411    async fn context_factory_should_build_output_in_order() {
412        let mut factory = ContextFactory::new();
413        factory.register(StaticAdapter::system("First"));
414        factory.register(StaticAdapter::user("Second"));
415
416        let input = ContextInput::new();
417        let output = factory.build(&input).await.unwrap();
418
419        assert_eq!(output.messages().len(), 2);
420    }
421
422    #[tokio::test]
423    async fn context_factory_should_build_request_with_tools() {
424        let mut factory = ContextFactory::new();
425        factory.register(StaticAdapter::system("You are helpful."));
426
427        let input = ContextInput::new().with_user_message("Hello");
428        let tools = vec![ToolSpec::new("echo", "Echo tool", json!({}))];
429        let request = factory
430            .build_request(&input, ModelName::new("gpt-4"), Some(&tools))
431            .await
432            .unwrap();
433
434        assert_eq!(request.messages.len(), 1);
435        assert_eq!(request.tools.len(), 1);
436    }
437
438    #[tokio::test]
439    async fn static_adapter_should_produce_system_message() {
440        let adapter = StaticAdapter::system("You are a helpful assistant.");
441        let input = ContextInput::new();
442        let messages = adapter.produce(&input).await.unwrap();
443
444        assert_eq!(messages.len(), 1);
445        assert!(matches!(messages[0], Message::System { .. }));
446    }
447
448    #[tokio::test]
449    async fn static_adapter_should_produce_user_message() {
450        let adapter = StaticAdapter::user("Hello");
451        let input = ContextInput::new();
452        let messages = adapter.produce(&input).await.unwrap();
453
454        assert_eq!(messages.len(), 1);
455        assert!(matches!(messages[0], Message::User { .. }));
456    }
457
458    #[tokio::test]
459    async fn function_adapter_should_invoke_handler() {
460        let adapter = FunctionAdapter::new("custom", |input: ContextInput| async move {
461            let msg = input.user_message.unwrap_or_default();
462            Ok(vec![Message::user_text(format!("Echo: {msg}"))])
463        });
464
465        let input = ContextInput::new().with_user_message("test");
466        let messages = adapter.produce(&input).await.unwrap();
467
468        assert_eq!(messages.len(), 1);
469    }
470}