Skip to main content

clawft_core/
bootstrap.rs

1//! Application bootstrap and dependency wiring.
2//!
3//! Provides [`AppContext`], a convenience struct that initializes all
4//! core components from a [`Config`] and a [`Platform`], then produces
5//! an [`AgentLoop`] ready to run.
6//!
7//! # Usage
8//!
9//! ```rust,ignore
10//! use clawft_core::bootstrap::AppContext;
11//! use clawft_platform::NativePlatform;
12//! use clawft_types::config::Config;
13//!
14//! let config = Config::default();
15//! let platform = Arc::new(NativePlatform::new());
16//! let ctx = AppContext::new(config, platform).await?;
17//! let agent_loop = ctx.into_agent_loop();
18//! agent_loop.run().await?;
19//! ```
20
21use std::sync::Arc;
22
23use tracing::{debug, info};
24
25use clawft_platform::Platform;
26use clawft_types::config::Config;
27
28use crate::agent::context::ContextBuilder;
29use crate::agent::loop_core::{AgentLoop, AutoDelegation};
30use crate::agent::memory::MemoryStore;
31use crate::agent::skills::SkillsLoader;
32use crate::bus::MessageBus;
33use crate::pipeline::assembler::TokenBudgetAssembler;
34use crate::pipeline::classifier::KeywordClassifier;
35use crate::pipeline::cost_tracker::CostTracker;
36use crate::pipeline::learner::NoopLearner;
37use crate::pipeline::rate_limiter::RateLimiter;
38use crate::pipeline::router::StaticRouter;
39use crate::pipeline::scorer::NoopScorer;
40use crate::pipeline::tiered_router::TieredRouter;
41use crate::pipeline::traits::{ModelRouter, Pipeline, PipelineRegistry};
42use crate::pipeline::transport::OpenAiCompatTransport;
43use crate::session::SessionManager;
44use crate::tools::registry::ToolRegistry;
45
46/// Fully initialized application context.
47///
48/// Holds all dependencies needed to run the agent loop. Created via
49/// [`AppContext::new`] and consumed via [`AppContext::into_agent_loop`].
50///
51/// The generic parameter `P` is the platform implementation (e.g.
52/// [`NativePlatform`](clawft_platform::NativePlatform) for native,
53/// a WASM platform for browser environments).
54pub struct AppContext<P: Platform> {
55    /// Root configuration.
56    config: Config,
57
58    /// Platform abstraction.
59    platform: Arc<P>,
60
61    /// Message bus for inbound/outbound message routing.
62    bus: Arc<MessageBus>,
63
64    /// Session manager for conversation persistence.
65    sessions: Arc<SessionManager<P>>,
66
67    /// Tool registry with registered tools.
68    tools: Arc<ToolRegistry>,
69
70    /// Pipeline registry with all 6 stages wired.
71    pipeline: PipelineRegistry,
72
73    /// Context builder for assembling LLM prompts.
74    context: ContextBuilder<P>,
75
76    /// Shared memory store reference (for external access).
77    memory: Arc<MemoryStore<P>>,
78
79    /// Shared skills loader reference (for external access).
80    skills: Arc<SkillsLoader<P>>,
81
82    /// Optional auto-delegation router for pre-LLM routing.
83    auto_delegation: Option<Arc<dyn AutoDelegation>>,
84}
85
86impl<P: Platform> AppContext<P> {
87    /// Initialize all components from configuration and platform.
88    ///
89    /// This is the primary constructor. It:
90    /// 1. Creates the [`MessageBus`]
91    /// 2. Initializes the [`SessionManager`] (discovers/creates sessions dir)
92    /// 3. Initializes the [`MemoryStore`] (discovers memory dir)
93    /// 4. Initializes the [`SkillsLoader`] (discovers skills dir)
94    /// 5. Creates the [`ContextBuilder`]
95    /// 6. Creates an empty [`ToolRegistry`] (caller registers tools after)
96    /// 7. Wires the default Level 0 pipeline (keyword classifier, static
97    ///    router, token budget assembler, stub transport, noop scorer,
98    ///    noop learner)
99    ///
100    /// # Errors
101    ///
102    /// Returns [`ClawftError`] if the home directory cannot be determined
103    /// or the sessions directory cannot be created.
104    pub async fn new(config: Config, platform: Arc<P>) -> clawft_types::Result<Self> {
105        info!("bootstrapping application context");
106
107        // 1. Message bus
108        let bus = Arc::new(MessageBus::new());
109        debug!("message bus created");
110
111        // 2. Session manager
112        let sessions = SessionManager::new(platform.clone()).await?;
113        debug!("session manager initialized");
114
115        // 3. Memory store
116        let memory = Arc::new(MemoryStore::new(platform.clone())?);
117        debug!(
118            memory_path = %memory.memory_path().display(),
119            "memory store initialized"
120        );
121
122        // 4. Skills loader
123        let mut skills_loader = SkillsLoader::new(platform.clone())?;
124
125        // Also scan extra `skills/` directories if they exist:
126        // - workspace/skills/  (from config workspace path)
127        // - ./skills/          (current working directory)
128        for candidate in [
129            config.workspace_path().join("skills"),
130            std::env::current_dir().unwrap_or_default().join("skills"),
131        ] {
132            if candidate.is_dir() {
133                skills_loader.add_extra_dir(candidate);
134            }
135        }
136
137        let skills = Arc::new(skills_loader);
138        debug!(
139            skills_dir = %skills.skills_dir().display(),
140            "skills loader initialized"
141        );
142
143        // 5. Context builder
144        let context = ContextBuilder::new(
145            config.agents.clone(),
146            memory.clone(),
147            skills.clone(),
148            platform.clone(),
149        );
150        debug!("context builder created");
151
152        // 6. Tool registry (empty -- caller adds tools)
153        let tools = Arc::new(ToolRegistry::new());
154
155        // 7. Default Level 0 pipeline
156        let pipeline = build_default_pipeline(&config);
157        debug!("default pipeline wired");
158
159        info!("bootstrap complete");
160
161        Ok(Self {
162            config,
163            platform,
164            bus,
165            sessions: Arc::new(sessions),
166            tools,
167            pipeline,
168            context,
169            memory,
170            skills,
171            auto_delegation: None,
172        })
173    }
174
175    /// Consume the context and produce a running [`AgentLoop`].
176    ///
177    /// All dependencies are moved into the agent loop. After this call,
178    /// the `AppContext` is consumed and cannot be reused.
179    pub fn into_agent_loop(self) -> AgentLoop<P> {
180        let resolver = crate::pipeline::permissions::PermissionResolver::new(
181            &self.config.routing,
182            None, // workspace config not yet supported
183        );
184        let mut agent = AgentLoop::new(
185            self.config.agents,
186            self.platform,
187            self.bus,
188            self.pipeline,
189            self.tools.clone(),
190            self.context,
191            self.sessions.clone(),
192            resolver,
193        );
194        if let Some(delegation) = self.auto_delegation {
195            agent = agent.with_auto_delegation(delegation);
196        }
197        agent
198    }
199
200    /// Get a reference to the root configuration.
201    pub fn config(&self) -> &Config {
202        &self.config
203    }
204
205    /// Get a reference to the platform.
206    pub fn platform(&self) -> &Arc<P> {
207        &self.platform
208    }
209
210    /// Get a reference to the message bus.
211    ///
212    /// Channel adapters should clone the inbound sender from here
213    /// before the context is consumed by [`into_agent_loop`].
214    pub fn bus(&self) -> &Arc<MessageBus> {
215        &self.bus
216    }
217
218    /// Get a mutable reference to the tool registry.
219    ///
220    /// Call this to register tools before converting to an agent loop.
221    ///
222    /// # Panics
223    ///
224    /// Panics if Arc clones have already been taken (e.g. via `tools_arc()`).
225    /// Always call `tools_mut()` for registration *before* sharing the registry.
226    pub fn tools_mut(&mut self) -> &mut ToolRegistry {
227        Arc::get_mut(&mut self.tools).expect("tools already shared -- register tools before cloning Arc")
228    }
229
230    /// Get a reference to the tool registry.
231    pub fn tools(&self) -> &Arc<ToolRegistry> {
232        &self.tools
233    }
234
235    /// Get a clone of the `Arc<ToolRegistry>` for sharing with other components.
236    pub fn tools_arc(&self) -> Arc<ToolRegistry> {
237        self.tools.clone()
238    }
239
240    /// Get a reference to the session manager.
241    pub fn sessions(&self) -> &Arc<SessionManager<P>> {
242        &self.sessions
243    }
244
245    /// Get a reference to the shared memory store.
246    pub fn memory(&self) -> &Arc<MemoryStore<P>> {
247        &self.memory
248    }
249
250    /// Get a reference to the shared skills loader.
251    pub fn skills(&self) -> &Arc<SkillsLoader<P>> {
252        &self.skills
253    }
254
255    /// Set an auto-delegation router for pre-LLM routing.
256    ///
257    /// When set, inbound messages are checked against delegation rules
258    /// before the local LLM is invoked. Matching messages are routed
259    /// directly to `delegate_task`.
260    pub fn set_auto_delegation(&mut self, delegation: Arc<dyn AutoDelegation>) {
261        self.auto_delegation = Some(delegation);
262    }
263
264    /// Replace the pipeline registry with a custom one.
265    ///
266    /// Use this to inject a transport backed by a real LLM provider
267    /// or to register specialized pipelines for specific task types.
268    pub fn set_pipeline(&mut self, pipeline: PipelineRegistry) {
269        self.pipeline = pipeline;
270    }
271
272    /// Replace the pipeline with a live LLM-backed pipeline.
273    ///
274    /// Convenience method that calls [`build_live_pipeline`] and sets it
275    /// as the active pipeline, enabling real LLM calls through the
276    /// `clawft-llm` provider resolved from configuration.
277    ///
278    /// Only available with the `native` feature.
279    #[cfg(feature = "native")]
280    pub fn enable_live_llm(&mut self) {
281        let pipeline = build_live_pipeline(&self.config);
282        self.set_pipeline(pipeline);
283    }
284}
285
286/// Build a live pipeline with a real LLM provider from configuration.
287///
288/// Uses [`ClawftLlmAdapter`](crate::pipeline::llm_adapter::ClawftLlmAdapter)
289/// to bridge `clawft-llm` providers into the pipeline's transport layer,
290/// enabling real LLM calls.
291///
292/// This is the production counterpart of [`build_default_pipeline`], which
293/// uses a stub transport. All other pipeline stages are identical.
294///
295/// Only available with the `native` feature (requires clawft-llm HTTP providers).
296#[cfg(feature = "native")]
297pub fn build_live_pipeline(config: &Config) -> PipelineRegistry {
298    crate::pipeline::llm_adapter::build_live_pipeline(config)
299}
300
301/// Build the default pipeline from configuration.
302///
303/// Uses the appropriate router based on `config.routing.mode`:
304/// - `"tiered"` -> [`TieredRouter`] with cost tracking and rate limiting
305/// - `"static"` (default) -> [`StaticRouter`] from config defaults
306///
307/// Other stages: [`KeywordClassifier`], [`TokenBudgetAssembler`],
308/// [`OpenAiCompatTransport`] (stub), [`NoopScorer`], [`NoopLearner`].
309fn build_default_pipeline(config: &Config) -> PipelineRegistry {
310    let classifier = Arc::new(KeywordClassifier::new());
311    let router: Arc<dyn ModelRouter> = build_router_from_config(config);
312    let assembler = Arc::new(TokenBudgetAssembler::new(
313        config.agents.defaults.max_tokens.max(1) as usize,
314    ));
315    let transport = Arc::new(OpenAiCompatTransport::new());
316    let scorer = Arc::new(NoopScorer::new());
317    let learner = Arc::new(NoopLearner::new());
318
319    let pipeline = Pipeline {
320        classifier,
321        router,
322        assembler,
323        transport,
324        scorer,
325        learner,
326    };
327
328    PipelineRegistry::new(pipeline)
329}
330
331/// Build the appropriate router based on `config.routing.mode`.
332fn build_router_from_config(config: &Config) -> Arc<dyn ModelRouter> {
333    if config.routing.mode == "tiered" {
334        let routing = config.routing.clone();
335        let cost_tracker = Arc::new(
336            CostTracker::new(routing.cost_budgets.reset_hour_utc),
337        );
338        let rate_limiter = Arc::new(
339            RateLimiter::new(routing.rate_limiting.window_seconds, routing.rate_limiting.global_rate_limit_rpm),
340        );
341        Arc::new(
342            TieredRouter::new(routing)
343                .with_cost_tracker(cost_tracker)
344                .with_rate_limiter(rate_limiter),
345        )
346    } else {
347        Arc::new(StaticRouter::from_config(&config.agents))
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use clawft_platform::NativePlatform;
355    use clawft_types::config::{AgentDefaults, AgentsConfig};
356    use std::sync::Arc;
357
358    fn test_config() -> Config {
359        Config {
360            agents: AgentsConfig {
361                defaults: AgentDefaults {
362                    workspace: "~/.clawft/workspace".into(),
363                    model: "deepseek/deepseek-chat".into(),
364                    max_tokens: 4096,
365                    temperature: 0.7,
366                    max_tool_iterations: 10,
367                    memory_window: 50,
368                },
369            },
370            ..Config::default()
371        }
372    }
373
374    #[tokio::test]
375    async fn new_creates_app_context() {
376        let platform = Arc::new(NativePlatform::new());
377        let ctx = AppContext::new(test_config(), platform).await;
378        assert!(ctx.is_ok());
379    }
380
381    #[tokio::test]
382    async fn config_accessor() {
383        let platform = Arc::new(NativePlatform::new());
384        let ctx = AppContext::new(test_config(), platform).await.unwrap();
385        assert_eq!(
386            ctx.config().agents.defaults.model,
387            "deepseek/deepseek-chat"
388        );
389        assert_eq!(ctx.config().agents.defaults.max_tokens, 4096);
390    }
391
392    #[tokio::test]
393    async fn platform_accessor() {
394        let platform = Arc::new(NativePlatform::new());
395        let ctx = AppContext::new(test_config(), platform).await.unwrap();
396        let _p = ctx.platform();
397    }
398
399    #[tokio::test]
400    async fn bus_accessor() {
401        let platform = Arc::new(NativePlatform::new());
402        let ctx = AppContext::new(test_config(), platform).await.unwrap();
403        let bus = ctx.bus();
404        // Should be able to get an inbound sender
405        let _tx = bus.inbound_sender();
406    }
407
408    #[tokio::test]
409    async fn tools_starts_empty() {
410        let platform = Arc::new(NativePlatform::new());
411        let ctx = AppContext::new(test_config(), platform).await.unwrap();
412        assert!(ctx.tools().is_empty());
413    }
414
415    #[tokio::test]
416    async fn tools_mut_allows_registration() {
417        let platform = Arc::new(NativePlatform::new());
418        let mut ctx = AppContext::new(test_config(), platform).await.unwrap();
419
420        use crate::tools::registry::Tool;
421        use async_trait::async_trait;
422
423        struct DummyTool;
424
425        #[async_trait]
426        impl Tool for DummyTool {
427            fn name(&self) -> &str {
428                "dummy"
429            }
430            fn description(&self) -> &str {
431                "A dummy tool"
432            }
433            fn parameters(&self) -> serde_json::Value {
434                serde_json::json!({"type": "object", "properties": {}})
435            }
436            async fn execute(
437                &self,
438                _args: serde_json::Value,
439            ) -> Result<serde_json::Value, crate::tools::registry::ToolError> {
440                Ok(serde_json::json!({}))
441            }
442        }
443
444        ctx.tools_mut().register(Arc::new(DummyTool));
445        assert_eq!(ctx.tools().len(), 1);
446        assert_eq!(ctx.tools().list(), vec!["dummy"]);
447    }
448
449    #[tokio::test]
450    async fn memory_accessor() {
451        let platform = Arc::new(NativePlatform::new());
452        let ctx = AppContext::new(test_config(), platform).await.unwrap();
453        let memory = ctx.memory();
454        assert!(memory.memory_path().is_absolute());
455    }
456
457    #[tokio::test]
458    async fn skills_accessor() {
459        let platform = Arc::new(NativePlatform::new());
460        let ctx = AppContext::new(test_config(), platform).await.unwrap();
461        let skills = ctx.skills();
462        assert!(skills.skills_dir().is_absolute());
463    }
464
465    #[tokio::test]
466    async fn into_agent_loop_produces_valid_loop() {
467        let platform = Arc::new(NativePlatform::new());
468        let ctx = AppContext::new(test_config(), platform).await.unwrap();
469        let agent = ctx.into_agent_loop();
470        assert_eq!(agent.config().defaults.model, "deepseek/deepseek-chat");
471        assert_eq!(agent.config().defaults.max_tokens, 4096);
472    }
473
474    #[tokio::test]
475    async fn set_pipeline_replaces_default() {
476        let platform = Arc::new(NativePlatform::new());
477        let mut ctx = AppContext::new(test_config(), platform).await.unwrap();
478
479        // Build a custom pipeline with a different router
480        let custom_pipeline = build_default_pipeline(&test_config());
481        ctx.set_pipeline(custom_pipeline);
482
483        // Should still produce a valid agent loop
484        let agent = ctx.into_agent_loop();
485        assert_eq!(agent.config().defaults.model, "deepseek/deepseek-chat");
486    }
487
488    #[tokio::test]
489    async fn default_config_bootstrap() {
490        let platform = Arc::new(NativePlatform::new());
491        let ctx = AppContext::new(Config::default(), platform).await;
492        assert!(ctx.is_ok());
493    }
494
495    #[test]
496    fn build_default_pipeline_creates_registry() {
497        let config = test_config();
498        let _registry = build_default_pipeline(&config);
499        // Should not panic.
500    }
501
502    #[test]
503    fn build_default_pipeline_with_defaults() {
504        let config = Config::default();
505        let _registry = build_default_pipeline(&config);
506    }
507
508    #[tokio::test]
509    async fn bus_clone_survives_into_agent_loop() {
510        let platform = Arc::new(NativePlatform::new());
511        let ctx = AppContext::new(test_config(), platform).await.unwrap();
512
513        // Clone the bus sender before consuming the context
514        let tx = ctx.bus().inbound_sender();
515        let _agent = ctx.into_agent_loop();
516
517        // The sender should still be usable
518        use chrono::Utc;
519        use clawft_types::event::InboundMessage;
520        use std::collections::HashMap;
521
522        let msg = InboundMessage {
523            channel: "test".into(),
524            sender_id: "user1".into(),
525            chat_id: "chat1".into(),
526            content: "hello".into(),
527            timestamp: Utc::now(),
528            media: vec![],
529            metadata: HashMap::new(),
530        };
531        assert!(tx.send(msg).await.is_ok());
532    }
533
534    #[tokio::test]
535    async fn app_context_is_send() {
536        fn assert_send<T: Send>() {}
537        assert_send::<AppContext<NativePlatform>>();
538    }
539
540    // ── Integration: build_live_pipeline transport is configured ─────
541
542    #[tokio::test]
543    async fn build_live_pipeline_transport_is_configured() {
544        use crate::pipeline::traits::{LlmMessage, TaskType, TransportRequest};
545
546        // The live pipeline should have a configured (non-stub) transport.
547        // When we call complete() on the stub transport, it returns an error
548        // containing "transport not configured". The live pipeline should
549        // NOT produce that error -- it should attempt a real HTTP call
550        // (which will fail differently without an API key).
551        let config = test_config();
552        let registry = build_live_pipeline(&config);
553
554        // Access the default pipeline's transport via get() with an unregistered type.
555        let pipeline = registry.get(&TaskType::Unknown);
556
557        let transport_req = TransportRequest {
558            provider: "anthropic".into(),
559            model: "claude-opus-4-5".into(),
560            messages: vec![LlmMessage {
561                role: "user".into(),
562                content: "test".into(),
563                tool_call_id: None,
564                tool_calls: None,
565            }],
566            tools: vec![],
567            max_tokens: Some(10),
568            temperature: Some(0.0),
569        };
570
571        let result = pipeline.transport.complete(&transport_req).await;
572
573        // The live transport IS configured (has a provider), so it should
574        // NOT fail with "transport not configured". It will fail with an
575        // HTTP/auth error instead because there's no real API key.
576        match result {
577            Ok(_) => {} // unlikely without API key, but acceptable
578            Err(e) => {
579                let msg = e.to_string();
580                assert!(
581                    !msg.contains("transport not configured"),
582                    "live pipeline should not use stub transport, but got: {msg}"
583                );
584            }
585        }
586    }
587
588    // ── Integration: enable_live_llm replaces the pipeline ──────────
589
590    #[tokio::test]
591    async fn app_context_enable_live_llm() {
592        let platform = Arc::new(NativePlatform::new());
593        let mut ctx = AppContext::new(test_config(), platform).await.unwrap();
594
595        // Before enable_live_llm, the default pipeline uses a stub transport.
596        // We cannot directly access ctx.pipeline, but we can verify behavior
597        // indirectly: enable_live_llm should not panic and should replace
598        // the pipeline such that transport errors no longer say "not configured".
599
600        ctx.enable_live_llm();
601
602        // Convert to agent loop and verify it's still valid.
603        let agent = ctx.into_agent_loop();
604        assert_eq!(agent.config().defaults.model, "deepseek/deepseek-chat");
605    }
606
607    // ── Integration: default pipeline uses stub (negative test) ─────
608
609    #[tokio::test]
610    async fn default_pipeline_transport_is_stub() {
611        // Verify the default pipeline's transport IS the stub, confirming
612        // that enable_live_llm / build_live_pipeline changes something.
613        use crate::pipeline::traits::{LlmMessage, TaskType, TransportRequest};
614
615        let config = test_config();
616        let registry = build_default_pipeline(&config);
617        let pipeline = registry.get(&TaskType::Unknown);
618
619        let transport_req = TransportRequest {
620            provider: "test".into(),
621            model: "test".into(),
622            messages: vec![LlmMessage {
623                role: "user".into(),
624                content: "hello".into(),
625                tool_call_id: None,
626                tool_calls: None,
627            }],
628            tools: vec![],
629            max_tokens: None,
630            temperature: None,
631        };
632
633        let result = pipeline.transport.complete(&transport_req).await;
634        assert!(result.is_err());
635        let err_msg = result.unwrap_err().to_string();
636        assert!(
637            err_msg.contains("transport not configured"),
638            "default pipeline should use stub transport, got: {err_msg}"
639        );
640    }
641
642    // ── Integration: build_live_pipeline creates a valid registry ────
643
644    #[test]
645    fn build_live_pipeline_creates_registry() {
646        let config = test_config();
647        let registry = build_live_pipeline(&config);
648        // Registry should be usable for any task type (falls back to default).
649        use crate::pipeline::traits::TaskType;
650        let _pipeline = registry.get(&TaskType::Chat);
651        let _pipeline = registry.get(&TaskType::CodeGeneration);
652        let _pipeline = registry.get(&TaskType::Unknown);
653    }
654}