1use 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
46pub struct AppContext<P: Platform> {
55 config: Config,
57
58 platform: Arc<P>,
60
61 bus: Arc<MessageBus>,
63
64 sessions: Arc<SessionManager<P>>,
66
67 tools: Arc<ToolRegistry>,
69
70 pipeline: PipelineRegistry,
72
73 context: ContextBuilder<P>,
75
76 memory: Arc<MemoryStore<P>>,
78
79 skills: Arc<SkillsLoader<P>>,
81
82 auto_delegation: Option<Arc<dyn AutoDelegation>>,
84}
85
86impl<P: Platform> AppContext<P> {
87 pub async fn new(config: Config, platform: Arc<P>) -> clawft_types::Result<Self> {
105 info!("bootstrapping application context");
106
107 let bus = Arc::new(MessageBus::new());
109 debug!("message bus created");
110
111 let sessions = SessionManager::new(platform.clone()).await?;
113 debug!("session manager initialized");
114
115 let memory = Arc::new(MemoryStore::new(platform.clone())?);
117 debug!(
118 memory_path = %memory.memory_path().display(),
119 "memory store initialized"
120 );
121
122 let mut skills_loader = SkillsLoader::new(platform.clone())?;
124
125 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 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 let tools = Arc::new(ToolRegistry::new());
154
155 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 pub fn into_agent_loop(self) -> AgentLoop<P> {
180 let resolver = crate::pipeline::permissions::PermissionResolver::new(
181 &self.config.routing,
182 None, );
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 pub fn config(&self) -> &Config {
202 &self.config
203 }
204
205 pub fn platform(&self) -> &Arc<P> {
207 &self.platform
208 }
209
210 pub fn bus(&self) -> &Arc<MessageBus> {
215 &self.bus
216 }
217
218 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 pub fn tools(&self) -> &Arc<ToolRegistry> {
232 &self.tools
233 }
234
235 pub fn tools_arc(&self) -> Arc<ToolRegistry> {
237 self.tools.clone()
238 }
239
240 pub fn sessions(&self) -> &Arc<SessionManager<P>> {
242 &self.sessions
243 }
244
245 pub fn memory(&self) -> &Arc<MemoryStore<P>> {
247 &self.memory
248 }
249
250 pub fn skills(&self) -> &Arc<SkillsLoader<P>> {
252 &self.skills
253 }
254
255 pub fn set_auto_delegation(&mut self, delegation: Arc<dyn AutoDelegation>) {
261 self.auto_delegation = Some(delegation);
262 }
263
264 pub fn set_pipeline(&mut self, pipeline: PipelineRegistry) {
269 self.pipeline = pipeline;
270 }
271
272 #[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#[cfg(feature = "native")]
297pub fn build_live_pipeline(config: &Config) -> PipelineRegistry {
298 crate::pipeline::llm_adapter::build_live_pipeline(config)
299}
300
301fn 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
331fn 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 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 let custom_pipeline = build_default_pipeline(&test_config());
481 ctx.set_pipeline(custom_pipeline);
482
483 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 }
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 let tx = ctx.bus().inbound_sender();
515 let _agent = ctx.into_agent_loop();
516
517 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 #[tokio::test]
543 async fn build_live_pipeline_transport_is_configured() {
544 use crate::pipeline::traits::{LlmMessage, TaskType, TransportRequest};
545
546 let config = test_config();
552 let registry = build_live_pipeline(&config);
553
554 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 match result {
577 Ok(_) => {} 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 #[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 ctx.enable_live_llm();
601
602 let agent = ctx.into_agent_loop();
604 assert_eq!(agent.config().defaults.model, "deepseek/deepseek-chat");
605 }
606
607 #[tokio::test]
610 async fn default_pipeline_transport_is_stub() {
611 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 #[test]
645 fn build_live_pipeline_creates_registry() {
646 let config = test_config();
647 let registry = build_live_pipeline(&config);
648 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}