1mod admin;
2mod agent;
3mod channels;
4mod cron;
5mod health;
6mod interview;
7pub(crate) mod mcp;
8mod memory;
9mod sessions;
10mod skills;
11pub(crate) mod subagent_integrity;
12mod themes;
13pub(crate) use self::agent::execute_scheduled_agent_task;
14mod observability;
15mod subagents;
16mod traces;
17
18use std::collections::HashMap;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use axum::extract::DefaultBodyLimit;
23use axum::response::IntoResponse;
24use axum::{
25 Router, middleware,
26 routing::{get, post, put},
27};
28use tokio::sync::RwLock;
29
30use crate::config_runtime::ConfigApplyStatus;
31use roboticus_agent::policy::PolicyEngine;
32use roboticus_agent::subagents::SubagentRegistry;
33use roboticus_browser::Browser;
34use roboticus_channels::a2a::A2aProtocol;
35use roboticus_channels::router::ChannelRouter;
36use roboticus_channels::telegram::TelegramAdapter;
37use roboticus_channels::whatsapp::WhatsAppAdapter;
38use roboticus_core::RoboticusConfig;
39use roboticus_db::Database;
41use roboticus_llm::LlmService;
42use roboticus_llm::OAuthManager;
43use roboticus_llm::semantic_classifier::SemanticClassifier;
44use roboticus_plugin_sdk::registry::PluginRegistry;
45use roboticus_wallet::WalletService;
46
47use roboticus_agent::approvals::ApprovalManager;
48use roboticus_agent::capability::CapabilityRegistry;
49use roboticus_agent::obsidian::ObsidianVault;
50use roboticus_agent::tools::ToolRegistry;
51use roboticus_channels::discord::DiscordAdapter;
52use roboticus_channels::email::EmailAdapter;
53use roboticus_channels::media::MediaService;
54use roboticus_channels::signal::SignalAdapter;
55use roboticus_channels::voice::VoicePipeline;
56
57use crate::ws::EventBus;
58
59#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
81pub struct ProblemDetails {
82 #[serde(rename = "type")]
85 pub problem_type: String,
86 pub title: String,
88 pub status: u16,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub detail: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub instance: Option<String>,
96}
97
98#[derive(Debug)]
101pub(crate) struct JsonError(pub axum::http::StatusCode, pub String);
102
103impl JsonError {
104 fn to_problem_details(&self) -> ProblemDetails {
106 ProblemDetails {
107 problem_type: "about:blank".into(),
108 title: canonical_reason(self.0).into(),
109 status: self.0.as_u16(),
110 detail: if self.1.is_empty() {
111 None
112 } else {
113 Some(self.1.clone())
114 },
115 instance: None,
116 }
117 }
118}
119
120fn canonical_reason(status: axum::http::StatusCode) -> &'static str {
122 status.canonical_reason().unwrap_or("Error")
123}
124
125impl axum::response::IntoResponse for JsonError {
126 fn into_response(self) -> axum::response::Response {
127 let status = self.0;
128 let body = self.to_problem_details();
129 (
130 status,
131 [(axum::http::header::CONTENT_TYPE, "application/problem+json")],
132 axum::Json(body),
133 )
134 .into_response()
135 }
136}
137
138impl From<(axum::http::StatusCode, String)> for JsonError {
139 fn from((status, msg): (axum::http::StatusCode, String)) -> Self {
140 Self(status, msg)
141 }
142}
143
144pub(crate) fn bad_request(msg: impl std::fmt::Display) -> JsonError {
146 JsonError(axum::http::StatusCode::BAD_REQUEST, msg.to_string())
147}
148
149pub(crate) fn not_found(msg: impl std::fmt::Display) -> JsonError {
151 JsonError(axum::http::StatusCode::NOT_FOUND, msg.to_string())
152}
153
154pub(crate) fn problem_json(status: axum::http::StatusCode, detail: &str) -> serde_json::Value {
161 serde_json::json!({
162 "type": "about:blank",
163 "title": canonical_reason(status),
164 "status": status.as_u16(),
165 "detail": detail,
166 })
167}
168
169pub(crate) fn problem_response(
171 status: axum::http::StatusCode,
172 detail: &str,
173) -> axum::response::Response {
174 (
175 status,
176 [(axum::http::header::CONTENT_TYPE, "application/problem+json")],
177 axum::Json(problem_json(status, detail)),
178 )
179 .into_response()
180}
181
182pub(crate) fn sanitize_error_message(msg: &str) -> String {
193 let sanitized = msg.lines().next().unwrap_or(msg);
194
195 let sanitized = sanitized
196 .trim_start_matches("Database(\"")
197 .trim_end_matches("\")")
198 .trim_start_matches("Wallet(\"")
199 .trim_end_matches("\")");
200
201 let sensitive_prefixes = [
204 "at /", "called `Result::unwrap()` on an `Err` value:",
206 "SQLITE_", "Connection refused", "constraint failed", "no such table", "no such column", "UNIQUE constraint", "FOREIGN KEY constraint", "NOT NULL constraint", ];
215 let sanitized = {
216 let mut s = sanitized.to_string();
217 for prefix in &sensitive_prefixes {
218 if let Some(pos) = s.find(prefix) {
219 s.truncate(pos);
220 s.push_str("[details redacted]");
221 break;
222 }
223 }
224 s
225 };
226
227 if sanitized.len() > 200 {
228 let boundary = sanitized
229 .char_indices()
230 .map(|(i, _)| i)
231 .take_while(|&i| i <= 200)
232 .last()
233 .unwrap_or(0);
234 format!("{}...", &sanitized[..boundary])
235 } else {
236 sanitized
237 }
238}
239
240pub(crate) fn internal_err(e: &impl std::fmt::Display) -> JsonError {
242 tracing::error!(error = %e, "request failed");
243 JsonError(
244 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
245 sanitize_error_message(&e.to_string()),
246 )
247}
248
249const MAX_SHORT_FIELD: usize = 256;
253const MAX_LONG_FIELD: usize = 4096;
255
256pub(crate) fn validate_field(
258 field_name: &str,
259 value: &str,
260 max_len: usize,
261) -> Result<(), JsonError> {
262 if value.trim().is_empty() {
263 return Err(bad_request(format!("{field_name} must not be empty")));
264 }
265 if value.contains('\0') {
266 return Err(bad_request(format!(
267 "{field_name} must not contain null bytes"
268 )));
269 }
270 if value.len() > max_len {
271 return Err(bad_request(format!(
272 "{field_name} exceeds max length ({max_len})"
273 )));
274 }
275 Ok(())
276}
277
278pub(crate) fn validate_short(field_name: &str, value: &str) -> Result<(), JsonError> {
280 validate_field(field_name, value, MAX_SHORT_FIELD)
281}
282
283pub(crate) fn validate_long(field_name: &str, value: &str) -> Result<(), JsonError> {
285 validate_field(field_name, value, MAX_LONG_FIELD)
286}
287
288pub(crate) fn sanitize_html(input: &str) -> String {
290 input
291 .replace('&', "&")
292 .replace('<', "<")
293 .replace('>', ">")
294 .replace('"', """)
295 .replace('\'', "'")
296}
297
298const DEFAULT_PAGE_SIZE: i64 = 200;
302const MAX_PAGE_SIZE: i64 = 500;
304
305#[derive(Debug, serde::Deserialize)]
307pub(crate) struct PaginationQuery {
308 pub limit: Option<i64>,
309 pub offset: Option<i64>,
310}
311
312impl PaginationQuery {
313 pub fn resolve(&self) -> (i64, i64) {
315 let limit = self
316 .limit
317 .unwrap_or(DEFAULT_PAGE_SIZE)
318 .clamp(1, MAX_PAGE_SIZE);
319 let offset = self.offset.unwrap_or(0).max(0);
320 (limit, offset)
321 }
322}
323
324pub use roboticus_pipeline::personality::PersonalityState;
327
328#[derive(Debug)]
330pub struct InterviewSession {
331 pub history: Vec<roboticus_llm::format::UnifiedMessage>,
332 pub awaiting_confirmation: bool,
333 pub pending_output: Option<roboticus_core::personality::InterviewOutput>,
334 pub created_at: std::time::Instant,
335}
336
337impl Default for InterviewSession {
338 fn default() -> Self {
339 Self::new()
340 }
341}
342
343impl InterviewSession {
344 pub fn new() -> Self {
345 Self {
346 history: vec![roboticus_llm::format::UnifiedMessage {
347 role: "system".into(),
348 content: roboticus_agent::interview::build_interview_prompt(),
349 parts: None,
350 }],
351 awaiting_confirmation: false,
352 pending_output: None,
353 created_at: std::time::Instant::now(),
354 }
355 }
356}
357
358#[derive(Clone)]
359pub struct AppState {
360 pub db: Database,
361 pub config: Arc<RwLock<RoboticusConfig>>,
362 pub llm: Arc<RwLock<LlmService>>,
363 pub wallet: Arc<WalletService>,
364 pub a2a: Arc<RwLock<A2aProtocol>>,
365 pub personality: Arc<RwLock<PersonalityState>>,
366 pub hmac_secret: Arc<Vec<u8>>,
367 pub interviews: Arc<RwLock<HashMap<String, InterviewSession>>>,
368 pub plugins: Arc<PluginRegistry>,
369 pub policy_engine: Arc<PolicyEngine>,
370 pub browser: Arc<Browser>,
371 pub registry: Arc<SubagentRegistry>,
372 pub event_bus: EventBus,
373 pub channel_router: Arc<ChannelRouter>,
374 pub telegram: Option<Arc<TelegramAdapter>>,
375 pub whatsapp: Option<Arc<WhatsAppAdapter>>,
376 pub retriever: Arc<roboticus_agent::retrieval::MemoryRetriever>,
377 pub ann_index: roboticus_db::ann::AnnIndex,
378 pub tools: Arc<ToolRegistry>,
379 pub capabilities: Arc<CapabilityRegistry>,
381 pub approvals: Arc<ApprovalManager>,
382 pub discord: Option<Arc<DiscordAdapter>>,
383 pub signal: Option<Arc<SignalAdapter>>,
384 pub email: Option<Arc<EmailAdapter>>,
385 pub voice: Option<Arc<RwLock<VoicePipeline>>>,
386 pub media_service: Option<Arc<MediaService>>,
387 pub discovery: Arc<RwLock<roboticus_agent::discovery::DiscoveryRegistry>>,
388 pub devices: Arc<RwLock<roboticus_agent::device::DeviceManager>>,
389 pub mcp_clients: Arc<RwLock<roboticus_agent::mcp::McpClientManager>>,
390 pub mcp_server: Arc<RwLock<roboticus_agent::mcp::McpServerRegistry>>,
391 pub live_mcp: Arc<roboticus_agent::mcp::manager::McpConnectionManager>,
392 pub oauth: Arc<OAuthManager>,
393 pub keystore: Arc<roboticus_core::keystore::Keystore>,
394 pub obsidian: Option<Arc<RwLock<ObsidianVault>>>,
395 pub started_at: std::time::Instant,
396 pub config_path: Arc<PathBuf>,
397 pub config_apply_status: Arc<RwLock<ConfigApplyStatus>>,
398 pub pending_specialist_proposals: Arc<RwLock<HashMap<String, serde_json::Value>>>,
399 pub ws_tickets: crate::ws_ticket::TicketStore,
400 pub rate_limiter: crate::rate_limit::GlobalRateLimitLayer,
401 pub semantic_classifier: Arc<SemanticClassifier>,
404}
405
406impl AppState {
407 pub async fn resync_capabilities_from_tools(&self) {
409 if let Err(e) = self
410 .capabilities
411 .sync_from_tool_registry(Arc::clone(&self.tools))
412 .await
413 {
414 tracing::warn!(error = %e, "capability resync from tools reported errors");
415 }
416 }
417
418 pub async fn reload_personality(&self) {
419 let workspace = {
420 let config = self.config.read().await;
421 config.agent.workspace.clone()
422 };
423 let new_state = PersonalityState::from_workspace(&workspace);
424 tracing::info!(
425 personality = %new_state.identity.name,
426 generated_by = %new_state.identity.generated_by,
427 "Hot-reloaded personality from workspace"
428 );
429 *self.personality.write().await = new_state;
430 }
431}
432
433impl AppState {
436 pub(crate) fn pipeline_deps(&self) -> roboticus_pipeline::stage_deps::PipelineDeps<'_> {
441 roboticus_pipeline::stage_deps::PipelineDeps {
442 core: self,
443 security: self,
444 reasoning: self,
445 retrieval: self,
446 coordination: self,
447 personality: self,
448 tooling: self,
449 execution: self,
450 tool_executor: self,
451 inference_runner: self,
452 }
453 }
454}
455
456impl roboticus_pipeline::capabilities::PipelineCore for AppState {
462 fn db(&self) -> &Database {
463 &self.db
464 }
465 fn config(&self) -> &Arc<RwLock<RoboticusConfig>> {
466 &self.config
467 }
468}
469
470impl roboticus_pipeline::capabilities::PipelineSecurity for AppState {
471 fn hmac_secret(&self) -> &[u8] {
472 &self.hmac_secret
473 }
474}
475
476impl roboticus_pipeline::capabilities::PipelineReasoning for AppState {
477 fn llm(&self) -> &Arc<RwLock<LlmService>> {
478 &self.llm
479 }
480 fn semantic_classifier(&self) -> &Arc<SemanticClassifier> {
481 &self.semantic_classifier
482 }
483}
484
485impl roboticus_pipeline::capabilities::PipelineRetrieval for AppState {
486 fn retriever(&self) -> &Arc<roboticus_agent::retrieval::MemoryRetriever> {
487 &self.retriever
488 }
489 fn ann_index(&self) -> &roboticus_db::ann::AnnIndex {
490 &self.ann_index
491 }
492}
493
494impl roboticus_pipeline::capabilities::PipelineCoordination for AppState {
495 fn channel_router(&self) -> &Arc<ChannelRouter> {
496 &self.channel_router
497 }
498 fn event_bus(&self) -> &roboticus_pipeline::event_bus::EventBus {
499 &self.event_bus
500 }
501}
502
503impl roboticus_pipeline::capabilities::PipelinePersonality for AppState {
504 fn personality(&self) -> &Arc<RwLock<PersonalityState>> {
505 &self.personality
506 }
507}
508
509impl roboticus_pipeline::capabilities::PipelineTooling for AppState {
510 fn tools(&self) -> &Arc<ToolRegistry> {
511 &self.tools
512 }
513 fn capabilities(&self) -> &Arc<CapabilityRegistry> {
514 &self.capabilities
515 }
516 fn policy_engine(&self) -> &Arc<PolicyEngine> {
517 &self.policy_engine
518 }
519 fn approvals(&self) -> &Arc<ApprovalManager> {
520 &self.approvals
521 }
522 fn registry(&self) -> &Arc<SubagentRegistry> {
523 &self.registry
524 }
525 fn plugins(&self) -> &Arc<PluginRegistry> {
526 &self.plugins
527 }
528 fn mcp_server(&self) -> &Arc<RwLock<roboticus_agent::mcp::McpServerRegistry>> {
529 &self.mcp_server
530 }
531 fn pending_specialist_proposals(&self) -> &Arc<RwLock<HashMap<String, serde_json::Value>>> {
532 &self.pending_specialist_proposals
533 }
534}
535
536impl roboticus_pipeline::capabilities::PipelineExecution for AppState {
537 fn keystore(&self) -> &Arc<roboticus_core::Keystore> {
538 &self.keystore
539 }
540 fn oauth(&self) -> &Arc<OAuthManager> {
541 &self.oauth
542 }
543 fn live_mcp(&self) -> &Arc<roboticus_agent::mcp::manager::McpConnectionManager> {
544 &self.live_mcp
545 }
546 fn mcp_clients(&self) -> &Arc<RwLock<roboticus_agent::mcp::McpClientManager>> {
547 &self.mcp_clients
548 }
549 fn browser(&self) -> &Arc<Browser> {
550 &self.browser
551 }
552}
553
554#[async_trait::async_trait]
555impl roboticus_pipeline::tool_executor::ToolExecutor for AppState {
556 async fn execute_tool(
557 &self,
558 tool_name: &str,
559 params: &serde_json::Value,
560 turn_id: &str,
561 authority: roboticus_core::InputAuthority,
562 channel: Option<&str>,
563 ) -> Result<String, String> {
564 agent::execute_tool_call(self, tool_name, params, turn_id, authority, channel).await
565 }
566
567 async fn execute_tool_detailed(
568 &self,
569 tool_name: &str,
570 params: &serde_json::Value,
571 turn_id: &str,
572 authority: roboticus_core::InputAuthority,
573 channel: Option<&str>,
574 ) -> Result<roboticus_pipeline::tool_executor::ToolExecutionDetails, String> {
575 let details =
576 agent::execute_tool_call_detailed(self, tool_name, params, turn_id, authority, channel)
577 .await?;
578 Ok(roboticus_pipeline::tool_executor::ToolExecutionDetails {
579 output: details.output,
580 source: details.source,
581 })
582 }
583}
584
585#[async_trait::async_trait]
586impl roboticus_pipeline::capabilities::InferenceRunner for AppState {
587 async fn select_and_audit_model(
588 &self,
589 user_content: &str,
590 session_id: &str,
591 turn_id: &str,
592 channel_label: &str,
593 complexity: Option<&str>,
594 ) -> String {
595 let audit =
596 agent::select_routed_model_with_audit(self, user_content, Some(session_id)).await;
597 let selected = audit.selected_model.clone();
598 agent::persist_model_selection_audit(
599 self,
600 self,
601 turn_id,
602 session_id,
603 channel_label,
604 complexity,
605 user_content,
606 &audit,
607 )
608 .await;
609 selected
610 }
611
612 async fn infer_with_fallback(
613 &self,
614 request: &roboticus_llm::format::UnifiedRequest,
615 initial_model: &str,
616 ) -> Result<roboticus_pipeline::core_types::InferenceResult, String> {
617 let result = agent::infer_with_fallback(self, request, initial_model).await?;
618 Ok(roboticus_pipeline::core_types::InferenceResult {
619 content: result.content,
620 model: result.model,
621 provider: result.provider,
622 tokens_in: result.tokens_in,
623 tokens_out: result.tokens_out,
624 cost: result.cost,
625 latency_ms: result.latency_ms,
626 quality_score: result.quality_score,
627 escalated: result.escalated,
628 })
629 }
630
631 async fn infer_stream_with_fallback(
632 &self,
633 request: &roboticus_llm::format::UnifiedRequest,
634 initial_model: &str,
635 ) -> Result<roboticus_pipeline::core_types::StreamResolvedInference, String> {
636 let ctx = agent::infer_stream_with_fallback(self, request, initial_model).await?;
637 Ok(roboticus_pipeline::core_types::StreamResolvedInference {
638 stream: ctx.stream,
639 selected_model: ctx.selected_model,
640 provider_prefix: ctx.provider_prefix,
641 cost_in: ctx.cost_in,
642 cost_out: ctx.cost_out,
643 })
644 }
645}
646
647async fn json_error_layer(
655 req: axum::extract::Request,
656 next: middleware::Next,
657) -> axum::response::Response {
658 let response = next.run(req).await;
659 let status = response.status();
660
661 if !(status.is_client_error() || status.is_server_error()) {
662 return response;
663 }
664
665 let content_type = response
666 .headers()
667 .get(axum::http::header::CONTENT_TYPE)
668 .and_then(|v| v.to_str().ok())
669 .unwrap_or("");
670 if content_type.contains("application/problem+json")
672 || content_type.contains("application/json")
673 {
674 return response;
675 }
676
677 let code = response.status();
678 let (_parts, body) = response.into_parts();
679 let bytes = match axum::body::to_bytes(body, 8192).await {
680 Ok(b) => b,
681 Err(e) => {
682 tracing::warn!(error = %e, "failed to read response body for JSON wrapping");
683 axum::body::Bytes::new()
684 }
685 };
686 let original_text = String::from_utf8_lossy(&bytes);
687
688 let error_msg = if original_text.trim().is_empty() {
689 match code {
690 axum::http::StatusCode::METHOD_NOT_ALLOWED => "method not allowed".to_string(),
691 axum::http::StatusCode::NOT_FOUND => "not found".to_string(),
692 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE => {
693 "unsupported content type: expected application/json".to_string()
694 }
695 other => other.to_string(),
696 }
697 } else {
698 sanitize_error_message(original_text.trim())
699 };
700
701 let json_body = problem_json(code, &error_msg);
702 let body_bytes = serde_json::to_vec(&json_body)
703 .unwrap_or_else(|_| br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"internal error"}"#.to_vec());
704 let mut resp = axum::response::Response::new(axum::body::Body::from(body_bytes));
705 *resp.status_mut() = code;
706 resp.headers_mut().insert(
707 axum::http::header::CONTENT_TYPE,
708 axum::http::HeaderValue::from_static("application/problem+json"),
709 );
710 resp
711}
712
713async fn security_headers_layer(
724 req: axum::extract::Request,
725 next: middleware::Next,
726) -> axum::response::Response {
727 let mut response = next.run(req).await;
728 let headers = response.headers_mut();
729
730 let csp_name = axum::http::header::HeaderName::from_static("content-security-policy");
733 if !headers.contains_key(&csp_name) {
734 headers.insert(
735 csp_name,
736 axum::http::HeaderValue::from_static(
737 "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'",
738 ),
739 );
740 }
741 headers.insert(
742 axum::http::header::X_FRAME_OPTIONS,
743 axum::http::HeaderValue::from_static("DENY"),
744 );
745 headers.insert(
746 axum::http::header::X_CONTENT_TYPE_OPTIONS,
747 axum::http::HeaderValue::from_static("nosniff"),
748 );
749 response
750}
751
752async fn dashboard_redirect() -> axum::response::Redirect {
753 axum::response::Redirect::permanent("/")
754}
755
756pub fn build_router(state: AppState) -> Router {
759 use admin::{
760 a2a_hello, breaker_open, breaker_reset, breaker_status, browser_action, browser_start,
761 browser_status, browser_stop, change_agent_model, confirm_revenue_swap_task,
762 confirm_revenue_tax_task, create_service_quote, delete_provider_key, execute_plugin_tool,
763 fail_revenue_swap_task, fail_revenue_tax_task, fail_service_request,
764 fulfill_revenue_opportunity, fulfill_service_request, generate_deep_analysis, get_agents,
765 get_available_models, get_cache_stats, get_capacity_stats, get_config,
766 get_config_apply_status, get_config_capabilities, get_config_raw, get_costs,
767 get_efficiency, get_mcp_runtime, get_memory_analytics, get_overview_timeseries,
768 get_plugins, get_recommendations, get_revenue_opportunity, get_routing_dataset,
769 get_routing_diagnostics, get_runtime_surfaces, get_service_request, get_task_events,
770 get_throttle_stats, get_transactions, get_workspace_tasks, intake_micro_bounty_opportunity,
771 intake_oracle_feed_opportunity, intake_revenue_opportunity, keystore_status,
772 keystore_unlock, list_discovered_agents, list_paired_devices, list_revenue_opportunities,
773 list_revenue_swap_tasks, list_revenue_tax_tasks, list_services_catalog,
774 mcp_client_disconnect, mcp_client_discover, pair_device, plan_revenue_opportunity,
775 qualify_revenue_opportunity, reconcile_revenue_swap_task, reconcile_revenue_tax_task,
776 record_revenue_opportunity_feedback, register_discovered_agent, roster, run_routing_eval,
777 score_revenue_opportunity, set_provider_key, settle_revenue_opportunity, start_agent,
778 start_revenue_swap_task, start_revenue_tax_task, stop_agent, submit_revenue_swap_task,
779 submit_revenue_tax_task, toggle_plugin, unpair_device, update_config, update_config_raw,
780 verify_discovered_agent, verify_paired_device, verify_service_payment, wallet_address,
781 wallet_balance, workspace_state,
782 };
783 use agent::{agent_message, agent_message_stream, agent_status};
784 use channels::{
785 get_channels_status, get_dead_letters, get_integrations, replay_dead_letter, test_channel,
786 };
787 use cron::{
788 create_cron_job, delete_cron_job, get_cron_job, list_cron_jobs, list_cron_runs,
789 run_cron_job_now, update_cron_job,
790 };
791 use health::{get_logs, health};
792 use memory::{
793 get_episodic_memory, get_semantic_categories, get_semantic_memory, get_semantic_memory_all,
794 get_working_memory, get_working_memory_all, knowledge_ingest, memory_health, memory_search,
795 trigger_consolidation, trigger_reindex,
796 };
797 use sessions::{
798 analyze_session, analyze_turn, archive_session_handler, backfill_nicknames, create_session,
799 get_session, get_session_feedback, get_session_insights, get_turn, get_turn_context,
800 get_turn_feedback, get_turn_model_selection, get_turn_tips, get_turn_tools, list_messages,
801 list_model_selection_events, list_session_turns, list_sessions, post_message,
802 post_turn_feedback, put_turn_feedback,
803 };
804 use skills::{
805 audit_skills, catalog_activate, catalog_install, catalog_list, delete_skill, get_skill,
806 list_skills, reload_skills, toggle_skill, update_skill,
807 };
808 use subagents::{
809 create_sub_agent, delete_sub_agent, get_subagent_retirement_candidates, list_sub_agents,
810 retire_unused_subagents, toggle_sub_agent, update_sub_agent,
811 };
812 use themes::{install_catalog_theme, list_theme_catalog, list_themes};
813
814 Router::new()
815 .route("/", get(crate::dashboard::dashboard_handler))
816 .route("/dashboard", get(dashboard_redirect))
817 .route("/dashboard/", get(dashboard_redirect))
818 .route("/api/health", get(health))
819 .route("/health", get(health))
820 .route("/api/config", get(get_config).put(update_config))
821 .route(
822 "/api/config/raw",
823 get(get_config_raw).put(update_config_raw),
824 )
825 .route("/api/config/capabilities", get(get_config_capabilities))
826 .route("/api/config/status", get(get_config_apply_status))
827 .route(
828 "/api/providers/{name}/key",
829 put(set_provider_key).delete(delete_provider_key),
830 )
831 .route("/api/keystore/status", get(keystore_status))
832 .route("/api/keystore/unlock", post(keystore_unlock))
833 .route("/api/logs", get(get_logs))
834 .route("/api/sessions", get(list_sessions).post(create_session))
835 .route("/api/sessions/backfill-nicknames", post(backfill_nicknames))
836 .route("/api/sessions/{id}", get(get_session))
837 .route(
838 "/api/sessions/{id}/messages",
839 get(list_messages).post(post_message),
840 )
841 .route("/api/sessions/{id}/turns", get(list_session_turns))
842 .route("/api/sessions/{id}/archive", post(archive_session_handler))
843 .route("/api/sessions/{id}/insights", get(get_session_insights))
844 .route("/api/sessions/{id}/feedback", get(get_session_feedback))
845 .route("/api/turns/{id}", get(get_turn))
846 .route("/api/turns/{id}/context", get(get_turn_context))
847 .route(
848 "/api/turns/{id}/model-selection",
849 get(get_turn_model_selection),
850 )
851 .route("/api/turns/{id}/tools", get(get_turn_tools))
852 .route("/api/turns/{id}/tips", get(get_turn_tips))
853 .route("/api/models/selections", get(list_model_selection_events))
854 .route(
855 "/api/turns/{id}/feedback",
856 get(get_turn_feedback)
857 .post(post_turn_feedback)
858 .put(put_turn_feedback),
859 )
860 .route("/api/memory/working", get(get_working_memory_all))
861 .route("/api/memory/working/{session_id}", get(get_working_memory))
862 .route("/api/memory/episodic", get(get_episodic_memory))
863 .route("/api/memory/semantic", get(get_semantic_memory_all))
864 .route(
865 "/api/memory/semantic/categories",
866 get(get_semantic_categories),
867 )
868 .route("/api/memory/semantic/{category}", get(get_semantic_memory))
869 .route("/api/memory/search", get(memory_search))
870 .route("/api/memory/health", get(memory_health))
871 .route("/api/memory/consolidate", post(trigger_consolidation))
872 .route("/api/memory/reindex", post(trigger_reindex))
873 .route("/api/knowledge/ingest", post(knowledge_ingest))
874 .route("/api/cron/jobs", get(list_cron_jobs).post(create_cron_job))
875 .route("/api/cron/runs", get(list_cron_runs))
876 .route(
877 "/api/cron/jobs/{id}",
878 get(get_cron_job)
879 .put(update_cron_job)
880 .delete(delete_cron_job),
881 )
882 .route(
883 "/api/cron/jobs/{id}/run",
884 axum::routing::post(run_cron_job_now),
885 )
886 .route("/api/stats/costs", get(get_costs))
887 .route("/api/stats/timeseries", get(get_overview_timeseries))
888 .route("/api/stats/efficiency", get(get_efficiency))
889 .route("/api/stats/memory-analytics", get(get_memory_analytics))
890 .route("/api/recommendations", get(get_recommendations))
891 .route("/api/stats/transactions", get(get_transactions))
892 .route("/api/services/catalog", get(list_services_catalog))
893 .route("/api/services/quote", post(create_service_quote))
894 .route("/api/services/requests/{id}", get(get_service_request))
895 .route(
896 "/api/services/requests/{id}/payment/verify",
897 post(verify_service_payment),
898 )
899 .route(
900 "/api/services/requests/{id}/fulfill",
901 post(fulfill_service_request),
902 )
903 .route(
904 "/api/services/requests/{id}/fail",
905 post(fail_service_request),
906 )
907 .route(
908 "/api/services/opportunities/intake",
909 get(list_revenue_opportunities).post(intake_revenue_opportunity),
910 )
911 .route(
912 "/api/services/opportunities/adapters/micro-bounty/intake",
913 post(intake_micro_bounty_opportunity),
914 )
915 .route(
916 "/api/services/opportunities/adapters/oracle-feed/intake",
917 post(intake_oracle_feed_opportunity),
918 )
919 .route(
920 "/api/services/opportunities/{id}",
921 get(get_revenue_opportunity),
922 )
923 .route(
924 "/api/services/opportunities/{id}/score",
925 post(score_revenue_opportunity),
926 )
927 .route(
928 "/api/services/opportunities/{id}/qualify",
929 post(qualify_revenue_opportunity),
930 )
931 .route(
932 "/api/services/opportunities/{id}/feedback",
933 post(record_revenue_opportunity_feedback),
934 )
935 .route(
936 "/api/services/opportunities/{id}/plan",
937 post(plan_revenue_opportunity),
938 )
939 .route(
940 "/api/services/opportunities/{id}/fulfill",
941 post(fulfill_revenue_opportunity),
942 )
943 .route(
944 "/api/services/opportunities/{id}/settle",
945 post(settle_revenue_opportunity),
946 )
947 .route("/api/services/swaps", get(list_revenue_swap_tasks))
948 .route("/api/services/tax-payouts", get(list_revenue_tax_tasks))
949 .route(
950 "/api/services/swaps/{id}/start",
951 post(start_revenue_swap_task),
952 )
953 .route(
954 "/api/services/swaps/{id}/submit",
955 post(submit_revenue_swap_task),
956 )
957 .route(
958 "/api/services/swaps/{id}/reconcile",
959 post(reconcile_revenue_swap_task),
960 )
961 .route(
962 "/api/services/swaps/{id}/confirm",
963 post(confirm_revenue_swap_task),
964 )
965 .route(
966 "/api/services/swaps/{id}/fail",
967 post(fail_revenue_swap_task),
968 )
969 .route(
970 "/api/services/tax-payouts/{id}/start",
971 post(start_revenue_tax_task),
972 )
973 .route(
974 "/api/services/tax-payouts/{id}/submit",
975 post(submit_revenue_tax_task),
976 )
977 .route(
978 "/api/services/tax-payouts/{id}/reconcile",
979 post(reconcile_revenue_tax_task),
980 )
981 .route(
982 "/api/services/tax-payouts/{id}/confirm",
983 post(confirm_revenue_tax_task),
984 )
985 .route(
986 "/api/services/tax-payouts/{id}/fail",
987 post(fail_revenue_tax_task),
988 )
989 .route("/api/stats/cache", get(get_cache_stats))
990 .route("/api/stats/capacity", get(get_capacity_stats))
991 .route("/api/stats/throttle", get(get_throttle_stats))
992 .route("/api/models/available", get(get_available_models))
993 .route(
994 "/api/models/routing-diagnostics",
995 get(get_routing_diagnostics),
996 )
997 .route("/api/models/routing-dataset", get(get_routing_dataset))
998 .route("/api/models/routing-eval", post(run_routing_eval))
999 .route("/api/models/reset", post(admin::reset_model_scores))
1000 .route("/api/breaker/status", get(breaker_status))
1001 .route("/api/breaker/open/{provider}", post(breaker_open))
1002 .route("/api/breaker/reset/{provider}", post(breaker_reset))
1003 .route("/api/agent/status", get(agent_status))
1004 .route("/api/agent/message", post(agent_message))
1005 .route("/api/agent/message/stream", post(agent_message_stream))
1006 .route("/api/wallet/balance", get(wallet_balance))
1007 .route("/api/wallet/address", get(wallet_address))
1008 .route("/api/skills", get(list_skills))
1009 .route("/api/skills/catalog", get(catalog_list))
1010 .route("/api/skills/catalog/install", post(catalog_install))
1011 .route("/api/skills/catalog/activate", post(catalog_activate))
1012 .route("/api/skills/audit", get(audit_skills))
1013 .route(
1014 "/api/skills/{id}",
1015 get(get_skill).put(update_skill).delete(delete_skill),
1016 )
1017 .route("/api/skills/reload", post(reload_skills))
1018 .route("/api/skills/{id}/toggle", put(toggle_skill))
1019 .route("/api/themes", get(list_themes))
1020 .route("/api/themes/catalog", get(list_theme_catalog))
1021 .route("/api/themes/catalog/install", post(install_catalog_theme))
1022 .route("/api/plugins/catalog/install", post(catalog_install))
1023 .route("/api/plugins", get(get_plugins))
1024 .route("/api/plugins/{name}/toggle", put(toggle_plugin))
1025 .route(
1026 "/api/plugins/{name}/execute/{tool}",
1027 post(execute_plugin_tool),
1028 )
1029 .route("/api/browser/status", get(browser_status))
1030 .route("/api/browser/start", post(browser_start))
1031 .route("/api/browser/stop", post(browser_stop))
1032 .route("/api/browser/action", post(browser_action))
1033 .route("/api/agents", get(get_agents))
1034 .route("/api/agents/{id}/start", post(start_agent))
1035 .route("/api/agents/{id}/stop", post(stop_agent))
1036 .route(
1037 "/api/subagents",
1038 get(list_sub_agents).post(create_sub_agent),
1039 )
1040 .route(
1041 "/api/subagents/retirement-candidates",
1042 get(get_subagent_retirement_candidates),
1043 )
1044 .route(
1045 "/api/subagents/retire-unused",
1046 post(retire_unused_subagents),
1047 )
1048 .route(
1049 "/api/subagents/{name}",
1050 put(update_sub_agent).delete(delete_sub_agent),
1051 )
1052 .route("/api/subagents/{name}/toggle", put(toggle_sub_agent))
1053 .route("/api/workspace/state", get(workspace_state))
1054 .route("/api/workspace/tasks", get(get_workspace_tasks))
1055 .route("/api/admin/task-events", get(get_task_events))
1056 .route("/api/roster", get(roster))
1057 .route("/api/roster/{name}/model", put(change_agent_model))
1058 .route("/api/a2a/hello", post(a2a_hello))
1059 .route("/api/channels/status", get(get_channels_status))
1060 .route("/api/integrations", get(get_integrations))
1061 .route("/api/channels/{platform}/test", post(test_channel))
1062 .route("/api/channels/dead-letter", get(get_dead_letters))
1063 .route(
1064 "/api/channels/dead-letter/{id}/replay",
1065 post(replay_dead_letter),
1066 )
1067 .route("/api/runtime/surfaces", get(get_runtime_surfaces))
1068 .route(
1069 "/api/runtime/discovery",
1070 get(list_discovered_agents).post(register_discovered_agent),
1071 )
1072 .route(
1073 "/api/runtime/discovery/{id}/verify",
1074 post(verify_discovered_agent),
1075 )
1076 .route("/api/runtime/devices", get(list_paired_devices))
1077 .route("/api/runtime/devices/pair", post(pair_device))
1078 .route(
1079 "/api/runtime/devices/{id}/verify",
1080 post(verify_paired_device),
1081 )
1082 .route(
1083 "/api/runtime/devices/{id}",
1084 axum::routing::delete(unpair_device),
1085 )
1086 .route("/api/runtime/mcp", get(get_mcp_runtime))
1087 .route(
1088 "/api/runtime/mcp/clients/{name}/discover",
1089 post(mcp_client_discover),
1090 )
1091 .route(
1092 "/api/runtime/mcp/clients/{name}/disconnect",
1093 post(mcp_client_disconnect),
1094 )
1095 .route("/api/mcp/servers", get(mcp::list_servers))
1096 .route("/api/mcp/servers/{name}", get(mcp::get_server))
1097 .route("/api/mcp/servers/{name}/test", post(mcp::test_server))
1098 .route("/api/approvals", get(admin::list_approvals))
1099 .route("/api/approvals/{id}/approve", post(admin::approve_request))
1100 .route("/api/approvals/{id}/deny", post(admin::deny_request))
1101 .route("/api/ws-ticket", post(admin::issue_ws_ticket))
1102 .route("/api/interview/start", post(interview::start_interview))
1103 .route("/api/interview/turn", post(interview::interview_turn))
1104 .route("/api/interview/finish", post(interview::finish_interview))
1105 .route("/api/audit/policy/{turn_id}", get(admin::get_policy_audit))
1106 .route("/api/audit/tools/{turn_id}", get(admin::get_tool_audit))
1107 .route("/api/traces/{turn_id}", get(traces::get_trace))
1108 .route(
1109 "/api/traces/{turn_id}/react",
1110 get(traces::get_react_trace_handler),
1111 )
1112 .route("/api/traces/{turn_id}/export", get(traces::export_trace))
1113 .route("/api/traces/{turn_id}/flow", get(traces::get_trace_flow))
1114 .route(
1115 "/api/traces/{turn_id}/replay",
1116 axum::routing::post(traces::replay_trace),
1117 )
1118 .route("/api/traces/search", get(traces::search_traces))
1119 .route("/api/observability/traces", get(observability::list_traces))
1120 .route(
1121 "/api/observability/traces/{turn_id}/waterfall",
1122 get(observability::trace_waterfall),
1123 )
1124 .route(
1125 "/api/observability/delegation/outcomes",
1126 get(observability::delegation_outcomes),
1127 )
1128 .route(
1129 "/api/observability/delegation/stats",
1130 get(observability::delegation_stats),
1131 )
1132 .route(
1133 "/favicon.ico",
1134 get(|| async { axum::http::StatusCode::NO_CONTENT }),
1135 )
1136 .merge(
1139 Router::new()
1140 .route("/api/sessions/{id}/analyze", post(analyze_session))
1141 .route("/api/turns/{id}/analyze", post(analyze_turn))
1142 .route(
1143 "/api/recommendations/generate",
1144 post(generate_deep_analysis),
1145 )
1146 .layer(tower::limit::ConcurrencyLimitLayer::new(3))
1147 .with_state(state.clone()),
1148 )
1149 .fallback(|| async { JsonError(axum::http::StatusCode::NOT_FOUND, "not found".into()) })
1150 .layer(DefaultBodyLimit::max(1024 * 1024)) .layer(middleware::from_fn(json_error_layer))
1152 .layer(middleware::from_fn(security_headers_layer))
1153 .with_state(state)
1155}
1156
1157pub fn build_public_router(state: AppState) -> Router {
1160 use admin::agent_card;
1161 use channels::{webhook_telegram, webhook_whatsapp, webhook_whatsapp_verify};
1162
1163 Router::new()
1164 .route("/.well-known/agent.json", get(agent_card))
1165 .route("/api/webhooks/telegram", post(webhook_telegram))
1166 .route(
1167 "/api/webhooks/whatsapp",
1168 get(webhook_whatsapp_verify).post(webhook_whatsapp),
1169 )
1170 .layer(DefaultBodyLimit::max(1024 * 1024)) .with_state(state)
1172}
1173
1174pub fn build_mcp_router(state: &AppState, api_key: Option<String>) -> Router {
1185 use crate::auth::ApiKeyLayer;
1186 use rmcp::transport::streamable_http_server::{
1187 StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
1188 };
1189 use roboticus_agent::mcp_handler::{McpToolContext, RoboticusMcpHandler};
1190 use std::time::Duration;
1191
1192 let mcp_ctx = {
1193 let (workspace_root, agent_name, tool_allowed_paths, sandbox) = state
1194 .config
1195 .try_read()
1196 .map(|c| {
1197 (
1198 c.agent.workspace.clone(),
1199 c.agent.name.clone(),
1200 c.security.filesystem.tool_allowed_paths.clone(),
1201 roboticus_agent::tools::ToolSandboxSnapshot::from_config(
1202 &c.security.filesystem,
1203 &c.skills,
1204 ),
1205 )
1206 })
1207 .unwrap_or_else(|_| {
1208 (
1209 std::path::PathBuf::from("."),
1210 "roboticus".to_string(),
1211 Vec::new(),
1212 roboticus_agent::tools::ToolSandboxSnapshot::default(),
1213 )
1214 });
1215 McpToolContext {
1216 agent_id: "roboticus-mcp-gateway".to_string(),
1217 agent_name,
1218 workspace_root,
1219 tool_allowed_paths,
1220 sandbox,
1221 db: Some(state.db.clone()),
1222 }
1223 };
1224
1225 let handler = RoboticusMcpHandler::new(state.tools.clone(), mcp_ctx);
1226
1227 let config = StreamableHttpServerConfig {
1228 sse_keep_alive: Some(Duration::from_secs(15)),
1229 stateful_mode: true,
1230 ..Default::default()
1231 };
1232
1233 let service = StreamableHttpService::new(
1234 move || Ok(handler.clone()),
1235 Arc::new(LocalSessionManager::default()),
1236 config,
1237 );
1238
1239 Router::new()
1240 .nest_service("/mcp", service)
1241 .layer(ApiKeyLayer::new(api_key))
1242}
1243
1244pub use agent::{discord_poll_loop, email_poll_loop, signal_poll_loop, telegram_poll_loop};
1247pub use health::LogEntry;
1248
1249#[cfg(test)]
1252#[path = "tests.rs"]
1253mod tests;