Skip to main content

behest_runtime/
components.rs

1#![allow(dead_code)]
2
3//! Self-contained [`Component`] implementations that construct providers,
4//! stores, and adapters from JSON configuration.
5//!
6//! Each wrapper type implements [`Component`] so it can be registered
7//! either directly via `TypedFactory`
8//! or through a [`FactoryRegistry`]
9//! using the convenience `register_*` functions or
10//! [`default_factory_registry`].
11
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use schemars::JsonSchema;
16use secrecy::SecretString;
17use serde::{Deserialize, Serialize};
18
19use super::memory::MemoryRunStore;
20use crate::component::AnyComponent;
21use crate::component::{Component, ComponentContext};
22use crate::context::ContextPipeline;
23use crate::factory_registry::{FactoryError, FactoryRegistry};
24use crate::registry::TypedAnyComponent;
25#[cfg(feature = "anthropic")]
26use behest_adapter_anthropic::chat::AnthropicChatAdapter;
27#[cfg(feature = "openai")]
28use behest_adapter_openai::chat::OpenAiChatAdapter;
29#[cfg(feature = "openai")]
30use behest_adapter_openai::embed::OpenAiEmbeddingAdapter;
31use behest_core::error::ProviderError;
32#[cfg(any(feature = "openai", feature = "anthropic"))]
33use behest_provider::ChatProvider;
34#[cfg(feature = "openai")]
35use behest_provider::EmbeddingProvider;
36use behest_provider::config::{DEFAULT_CONNECT_TIMEOUT, DEFAULT_TIMEOUT};
37use behest_provider::{ProviderHttpConfig, ProviderId};
38use behest_store::memory::{
39    MemoryArtifactStore, MemoryEmbeddingStore, MemoryExecutionStore, MemorySessionStore,
40};
41
42// ---------------------------------------------------------------------------
43// JSON-serializable config for HTTP-backed providers
44// ---------------------------------------------------------------------------
45
46/// JSON-deserializable configuration for HTTP-backed providers like OpenAI
47/// and Anthropic. Uses plain `String` for the API key (instead of
48/// [`SecretString`]) so that it can be deserialised from JSON/YAML/TOML.
49#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
50pub struct ProviderHttpComponentConfig {
51    /// Logical provider identifier.
52    pub id: String,
53    /// Provider API base URL.
54    pub base_url: String,
55    /// Optional API key or bearer token.
56    pub api_key: Option<String>,
57    /// Optional organization or tenant identifier.
58    pub organization: Option<String>,
59    /// End-to-end request timeout in seconds (default: 60).
60    pub timeout_secs: Option<u64>,
61    /// TCP connection timeout in seconds (default: 10).
62    pub connect_timeout_secs: Option<u64>,
63    /// Maximum retry attempts (default: 2).
64    pub max_retries: Option<usize>,
65}
66
67impl ProviderHttpComponentConfig {
68    /// Converts this JSON config into a [`ProviderHttpConfig`], wrapping
69    /// the plain-text API key in a [`SecretString`].
70    #[must_use]
71    pub fn into_provider_http_config(self) -> ProviderHttpConfig {
72        let mut cfg = ProviderHttpConfig::new(ProviderId::new(&self.id), self.base_url);
73        if let Some(key) = self.api_key {
74            cfg = cfg.with_api_key(SecretString::new(key.into()));
75        }
76        if let Some(org) = self.organization {
77            cfg = cfg.with_organization(org);
78        }
79        let timeout = self
80            .timeout_secs
81            .map_or(DEFAULT_TIMEOUT, std::time::Duration::from_secs);
82        let connect_timeout = self
83            .connect_timeout_secs
84            .map_or(DEFAULT_CONNECT_TIMEOUT, std::time::Duration::from_secs);
85        cfg = cfg.with_timeouts(timeout, connect_timeout);
86        cfg = cfg.with_max_retries(self.max_retries.unwrap_or(2));
87        cfg
88    }
89}
90
91// ---------------------------------------------------------------------------
92// Unified error type for config-constructed components
93// ---------------------------------------------------------------------------
94
95/// Error type for components constructed from configuration.
96#[derive(Debug, thiserror::Error)]
97pub enum ComponentError {
98    /// Provider construction or lifecycle failure.
99    #[error("provider error: {0}")]
100    Provider(String),
101    /// Store construction failure.
102    #[error("store error: {0}")]
103    Store(String),
104    /// Context pipeline construction failure.
105    #[error("context error: {0}")]
106    Context(String),
107}
108
109impl From<ProviderError> for ComponentError {
110    fn from(e: ProviderError) -> Self {
111        Self::Provider(e.to_string())
112    }
113}
114
115// ---------------------------------------------------------------------------
116// Provider components — macro-generated because they share identical shape
117// ---------------------------------------------------------------------------
118
119/// Generates a [`Component`] wrapper for a provider adapter.
120#[cfg(any(feature = "openai", feature = "anthropic"))]
121macro_rules! provider_component {
122    (
123        $(#[$outer:meta])*
124        $vis:vis struct $Name:ident {
125            inner: Arc<$InnerTrait:ty>,
126        },
127        const NAME: &'static str = $name:literal;
128        adapter = $Adapter:ty;
129    ) => {
130        $(#[$outer])*
131        $vis struct $Name {
132            inner: Arc<$InnerTrait>,
133        }
134
135        #[async_trait]
136        impl Component for $Name {
137            const NAME: &'static str = $name;
138            type Config = ProviderHttpComponentConfig;
139            type Error = ComponentError;
140
141            async fn init(cfg: &Self::Config, _ctx: &ComponentContext) -> Result<Self, Self::Error> {
142                let http = cfg.clone().into_provider_http_config();
143                let adapter = <$Adapter>::new(http)?;
144                Ok(Self {
145                    inner: Arc::new(adapter),
146                })
147            }
148        }
149    };
150}
151
152#[cfg(feature = "openai")]
153provider_component! {
154    /// Component wrapper for [`OpenAiChatAdapter`].
155    pub struct OpenAiChatComponent {
156        inner: Arc<dyn ChatProvider>,
157    },
158    const NAME: &'static str = "provider.openai.chat";
159    adapter = OpenAiChatAdapter;
160}
161
162#[cfg(feature = "anthropic")]
163provider_component! {
164    /// Component wrapper for [`AnthropicChatAdapter`].
165    pub struct AnthropicChatComponent {
166        inner: Arc<dyn ChatProvider>,
167    },
168    const NAME: &'static str = "provider.anthropic.chat";
169    adapter = AnthropicChatAdapter;
170}
171
172#[cfg(feature = "openai")]
173provider_component! {
174    /// Component wrapper for [`OpenAiEmbeddingAdapter`].
175    pub struct OpenAiEmbeddingComponent {
176        inner: Arc<dyn EmbeddingProvider>,
177    },
178    const NAME: &'static str = "provider.openai.embedding";
179    adapter = OpenAiEmbeddingAdapter;
180}
181
182// ---------------------------------------------------------------------------
183// Memory store components — macro-generated because they share identical shape
184// ---------------------------------------------------------------------------
185
186/// Generates a [`Component`] wrapper for a memory-store implementation.
187///
188/// Each generated type has:
189/// - A struct wrapping `Arc<StoreType>`
190/// - A `Component` impl with `Config = serde_json::Value` and a no-op `init`
191macro_rules! memory_store_component {
192    (
193        $(#[$outer:meta])*
194        $vis:vis struct $Name:ident {
195            inner: Arc<$Store:ty>,
196        },
197        const NAME: &'static str = $name:literal;
198    ) => {
199        $(#[$outer])*
200        $vis struct $Name {
201            inner: Arc<$Store>,
202        }
203
204        #[async_trait]
205        impl Component for $Name {
206            const NAME: &'static str = $name;
207            type Config = serde_json::Value;
208            type Error = ComponentError;
209
210            async fn init(_cfg: &Self::Config, _ctx: &ComponentContext) -> Result<Self, Self::Error> {
211                Ok(Self {
212                    inner: Arc::new(<$Store>::new()),
213                })
214            }
215        }
216    };
217}
218
219memory_store_component! {
220    /// Component wrapper for [`MemorySessionStore`].
221    pub struct MemorySessionStoreComponent {
222        inner: Arc<MemorySessionStore>,
223    },
224    const NAME: &'static str = "store.session.memory";
225}
226
227memory_store_component! {
228    /// Component wrapper for [`MemoryExecutionStore`].
229    pub struct MemoryExecutionStoreComponent {
230        inner: Arc<MemoryExecutionStore>,
231    },
232    const NAME: &'static str = "store.execution.memory";
233}
234
235memory_store_component! {
236    /// Component wrapper for [`MemoryRunStore`].
237    pub struct MemoryRunStoreComponent {
238        inner: Arc<MemoryRunStore>,
239    },
240    const NAME: &'static str = "store.run.memory";
241}
242
243memory_store_component! {
244    /// Component wrapper for [`MemoryEmbeddingStore`].
245    pub struct MemoryEmbeddingStoreComponent {
246        inner: Arc<MemoryEmbeddingStore>,
247    },
248    const NAME: &'static str = "store.embedding.memory";
249}
250
251memory_store_component! {
252    /// Component wrapper for [`MemoryArtifactStore`].
253    pub struct MemoryArtifactStoreComponent {
254        inner: Arc<MemoryArtifactStore>,
255    },
256    const NAME: &'static str = "store.artifact.memory";
257}
258
259// ---------------------------------------------------------------------------
260// Context pipeline component
261// ---------------------------------------------------------------------------
262
263/// JSON configuration for [`ContextPipelineComponent`].
264#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
265pub struct ContextPipelineConfig {
266    /// Maximum number of history messages (default: 50).
267    pub max_history_messages: Option<usize>,
268    /// Maximum token budget for history (default: 64000).
269    pub max_history_tokens: Option<usize>,
270    /// Enable compaction filter (default: true).
271    pub enable_compaction_filter: Option<bool>,
272}
273
274/// Component wrapper for [`ContextPipeline`].
275pub struct ContextPipelineComponent {
276    inner: Arc<ContextPipeline>,
277}
278
279#[async_trait]
280impl Component for ContextPipelineComponent {
281    const NAME: &'static str = "context.pipeline";
282    type Config = ContextPipelineConfig;
283    type Error = ComponentError;
284
285    async fn init(cfg: &Self::Config, _ctx: &ComponentContext) -> Result<Self, Self::Error> {
286        let mut pipeline = ContextPipeline::new();
287        if let Some(v) = cfg.max_history_messages {
288            pipeline = pipeline.with_max_history(v);
289        }
290        if let Some(v) = cfg.max_history_tokens {
291            pipeline = pipeline.with_max_history_tokens(v);
292        }
293        if let Some(v) = cfg.enable_compaction_filter {
294            pipeline = pipeline.with_compaction_filter(v);
295        }
296        Ok(Self {
297            inner: Arc::new(pipeline),
298        })
299    }
300}
301
302// ---------------------------------------------------------------------------
303// Convenience registration helpers
304// ---------------------------------------------------------------------------
305
306/// Registers all provider factory invokers into a [`FactoryRegistry`].
307///
308/// Registered kinds:
309/// - `"provider.openai.chat"`
310/// - `"provider.anthropic.chat"`
311/// - `"provider.openai.embedding"`
312#[must_use]
313pub fn register_providers(registry: FactoryRegistry) -> FactoryRegistry {
314    #[cfg(any(feature = "openai", feature = "anthropic"))]
315    {
316        let mut registry = registry;
317        macro_rules! register {
318            ($kind:literal, $Comp:ident, $Adapter:ty) => {
319                registry = registry.register($kind, |cfg, _ctx| {
320                    let v: ProviderHttpComponentConfig =
321                        serde_json::from_value(cfg).map_err(|e| FactoryError::InvalidConfig {
322                            kind: $kind.into(),
323                            source: e,
324                        })?;
325                    let http = v.into_provider_http_config();
326                    let adapter = <$Adapter>::new(http)
327                        .map_err(|e| FactoryError::FactoryFailed($kind.into(), e.to_string()))?;
328                    let comp = $Comp {
329                        inner: Arc::new(adapter),
330                    };
331                    Ok(Box::new(TypedAnyComponent::new(comp)) as Box<dyn AnyComponent>)
332                });
333            };
334        }
335
336        #[cfg(feature = "openai")]
337        {
338            register!(
339                "provider.openai.chat",
340                OpenAiChatComponent,
341                OpenAiChatAdapter
342            );
343            register!(
344                "provider.openai.embedding",
345                OpenAiEmbeddingComponent,
346                OpenAiEmbeddingAdapter
347            );
348        }
349        #[cfg(feature = "anthropic")]
350        {
351            register!(
352                "provider.anthropic.chat",
353                AnthropicChatComponent,
354                AnthropicChatAdapter
355            );
356        }
357
358        registry
359    }
360
361    #[cfg(not(any(feature = "openai", feature = "anthropic")))]
362    {
363        registry
364    }
365}
366
367/// Registers all memory-store factory invokers into a [`FactoryRegistry`].
368///
369/// Registered kinds:
370/// - `"store.session.memory"`
371/// - `"store.execution.memory"`
372/// - `"store.run.memory"`
373/// - `"store.embedding.memory"`
374/// - `"store.artifact.memory"`
375#[must_use]
376pub fn register_memory_stores(mut registry: FactoryRegistry) -> FactoryRegistry {
377    macro_rules! register {
378        ($kind:literal, $Comp:ident, $Store:ty) => {
379            registry = registry.register($kind, |cfg, ctx| {
380                let _ = (cfg, &ctx);
381                let comp = $Comp {
382                    inner: Arc::new(<$Store>::new()),
383                };
384                Ok(Box::new(TypedAnyComponent::new(comp)) as Box<dyn AnyComponent>)
385            });
386        };
387    }
388    register!(
389        "store.session.memory",
390        MemorySessionStoreComponent,
391        MemorySessionStore
392    );
393    register!(
394        "store.execution.memory",
395        MemoryExecutionStoreComponent,
396        MemoryExecutionStore
397    );
398    register!("store.run.memory", MemoryRunStoreComponent, MemoryRunStore);
399    register!(
400        "store.embedding.memory",
401        MemoryEmbeddingStoreComponent,
402        MemoryEmbeddingStore
403    );
404    register!(
405        "store.artifact.memory",
406        MemoryArtifactStoreComponent,
407        MemoryArtifactStore
408    );
409    registry
410}
411
412/// Registers the context-pipeline factory invoker into a [`FactoryRegistry`].
413///
414/// Registered kind:
415/// - `"context.pipeline"`
416#[must_use]
417pub fn register_context_pipeline(registry: FactoryRegistry) -> FactoryRegistry {
418    registry.register("context.pipeline", |cfg, _ctx| {
419        let v: ContextPipelineConfig =
420            serde_json::from_value(cfg).map_err(|e| FactoryError::InvalidConfig {
421                kind: "context.pipeline".into(),
422                source: e,
423            })?;
424        let mut pipeline = ContextPipeline::new();
425        if let Some(v) = v.max_history_messages {
426            pipeline = pipeline.with_max_history(v);
427        }
428        if let Some(v) = v.max_history_tokens {
429            pipeline = pipeline.with_max_history_tokens(v);
430        }
431        if let Some(v) = v.enable_compaction_filter {
432            pipeline = pipeline.with_compaction_filter(v);
433        }
434        let comp = ContextPipelineComponent {
435            inner: Arc::new(pipeline),
436        };
437        Ok(Box::new(TypedAnyComponent::new(comp)) as Box<dyn AnyComponent>)
438    })
439}
440
441/// Returns a [`FactoryRegistry`] pre-populated with all built-in component
442/// factory invokers:
443///
444/// - Provider adapters (OpenAI chat, Anthropic chat, OpenAI embedding)
445/// - Memory stores (session, execution, run, embedding, artifact)
446/// - Context pipeline
447#[must_use]
448pub fn default_factory_registry() -> FactoryRegistry {
449    let reg = FactoryRegistry::new();
450    let reg = register_providers(reg);
451    let reg = register_memory_stores(reg);
452    register_context_pipeline(reg)
453}
454
455#[cfg(test)]
456mod tests {
457    #![allow(clippy::expect_used)]
458    use super::*;
459    use crate::factory_registry::FactoryError;
460    use crate::lifecycle::ShutdownToken;
461
462    fn ctx() -> ComponentContext {
463        ComponentContext::new(ShutdownToken::new())
464    }
465
466    #[test]
467    fn default_registry_contains_all_kinds() {
468        let reg = default_factory_registry();
469        let kinds: Vec<&str> = reg.kinds().collect();
470        assert!(kinds.contains(&"store.session.memory"));
471        assert!(kinds.contains(&"store.execution.memory"));
472        assert!(kinds.contains(&"store.run.memory"));
473        assert!(kinds.contains(&"store.embedding.memory"));
474        assert!(kinds.contains(&"store.artifact.memory"));
475        assert!(kinds.contains(&"context.pipeline"));
476
477        let expected_providers: usize =
478            usize::from(cfg!(feature = "openai")) * 2 + usize::from(cfg!(feature = "anthropic"));
479        assert_eq!(kinds.len(), 6 + expected_providers);
480
481        #[cfg(feature = "openai")]
482        {
483            assert!(kinds.contains(&"provider.openai.chat"));
484            assert!(kinds.contains(&"provider.openai.embedding"));
485        }
486        #[cfg(feature = "anthropic")]
487        {
488            assert!(kinds.contains(&"provider.anthropic.chat"));
489        }
490    }
491
492    #[test]
493    fn default_registry_rejects_unknown_kind() {
494        let reg = default_factory_registry();
495        let result = reg.invoke("nope", serde_json::json!({}), &ctx());
496        assert!(matches!(result, Err(FactoryError::UnknownKind(_))));
497    }
498
499    #[test]
500    fn memory_store_invocations() {
501        for kind in &[
502            "store.session.memory",
503            "store.execution.memory",
504            "store.run.memory",
505            "store.embedding.memory",
506            "store.artifact.memory",
507        ] {
508            let reg = default_factory_registry();
509            let comp = reg
510                .invoke(kind, serde_json::json!({}), &ctx())
511                .expect("invoke should succeed");
512            assert_eq!(comp.name(), *kind);
513        }
514    }
515
516    #[tokio::test]
517    async fn context_pipeline_invocation() {
518        let reg = default_factory_registry();
519        let comp = reg
520            .invoke(
521                "context.pipeline",
522                serde_json::json!({
523                    "max_history_messages": 100,
524                }),
525                &ctx(),
526            )
527            .expect("invoke should succeed");
528        assert_eq!(comp.name(), "context.pipeline");
529    }
530
531    #[cfg(feature = "openai")]
532    #[test]
533    fn provider_openai_chat_invocation_succeeds_without_api_key() {
534        // Adapter constructors are lazy — they don't validate credentials
535        // at construction time, only at request time.
536        let reg = default_factory_registry();
537        let result = reg.invoke(
538            "provider.openai.chat",
539            serde_json::json!({
540                "id": "test",
541                "base_url": "https://api.openai.com/v1",
542            }),
543            &ctx(),
544        );
545        assert!(result.is_ok());
546    }
547
548    #[cfg(feature = "openai")]
549    #[test]
550    fn provider_openai_chat_invocation_fails_with_bad_config() {
551        let reg = default_factory_registry();
552        let result = reg.invoke(
553            "provider.openai.chat",
554            serde_json::json!({ "bad_field": true }),
555            &ctx(),
556        );
557        assert!(matches!(result, Err(FactoryError::InvalidConfig { .. })));
558    }
559
560    #[test]
561    fn register_returns_chained_registry() {
562        let reg = FactoryRegistry::new();
563        let reg = register_memory_stores(reg);
564        assert!(reg.contains("store.session.memory"));
565        assert!(reg.contains("store.execution.memory"));
566    }
567
568    #[test]
569    fn register_providers_returns_chained_registry() {
570        let reg = FactoryRegistry::new();
571        let reg = register_providers(reg);
572        #[cfg(feature = "openai")]
573        {
574            assert!(reg.contains("provider.openai.chat"));
575            assert!(reg.contains("provider.openai.embedding"));
576        }
577        #[cfg(feature = "anthropic")]
578        {
579            assert!(reg.contains("provider.anthropic.chat"));
580        }
581    }
582
583    #[test]
584    fn register_context_pipeline_returns_chained_registry() {
585        let reg = FactoryRegistry::new();
586        let reg = register_context_pipeline(reg);
587        assert!(reg.contains("context.pipeline"));
588    }
589
590    #[test]
591    fn provider_http_component_config_roundtrip() {
592        let json = serde_json::json!({
593            "id": "my-openai",
594            "base_url": "https://api.openai.com/v1",
595            "api_key": "sk-test123",
596            "organization": "org-abc",
597            "timeout_secs": 30,
598            "connect_timeout_secs": 5,
599            "max_retries": 3,
600        });
601        let cfg: ProviderHttpComponentConfig =
602            serde_json::from_value(json).expect("deserialize should succeed");
603        assert_eq!(cfg.id, "my-openai");
604        assert_eq!(cfg.base_url, "https://api.openai.com/v1");
605        assert_eq!(cfg.api_key.as_deref(), Some("sk-test123"));
606        assert_eq!(cfg.organization.as_deref(), Some("org-abc"));
607        assert_eq!(cfg.timeout_secs, Some(30));
608        assert_eq!(cfg.connect_timeout_secs, Some(5));
609        assert_eq!(cfg.max_retries, Some(3));
610
611        let http = cfg.into_provider_http_config();
612        assert_eq!(http.id.as_str(), "my-openai");
613        assert_eq!(http.base_url, "https://api.openai.com/v1");
614    }
615
616    #[test]
617    fn provider_http_component_config_defaults() {
618        let json = serde_json::json!({
619            "id": "minimal",
620            "base_url": "https://example.com",
621        });
622        let cfg: ProviderHttpComponentConfig =
623            serde_json::from_value(json).expect("deserialize should succeed");
624        assert_eq!(cfg.id, "minimal");
625        assert!(cfg.api_key.is_none());
626        assert!(cfg.organization.is_none());
627
628        let http = cfg.into_provider_http_config();
629        assert_eq!(http.max_retries, 2);
630    }
631
632    #[test]
633    fn context_pipeline_config_roundtrip() {
634        let json = serde_json::json!({
635            "max_history_messages": 100,
636            "max_history_tokens": 128_000,
637            "enable_compaction_filter": false,
638        });
639        let cfg: ContextPipelineConfig =
640            serde_json::from_value(json).expect("deserialize should succeed");
641        assert_eq!(cfg.max_history_messages, Some(100));
642        assert_eq!(cfg.max_history_tokens, Some(128_000));
643        assert_eq!(cfg.enable_compaction_filter, Some(false));
644    }
645}