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_core::personality::{self, OsIdentity, OsVoice};
40use 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
324#[derive(Debug, Clone)]
328pub struct PersonalityState {
329 pub os_text: String,
330 pub firmware_text: String,
331 pub identity: OsIdentity,
332 pub voice: OsVoice,
333}
334
335impl PersonalityState {
336 pub fn from_workspace(workspace: &std::path::Path) -> Self {
337 let os = personality::load_os(workspace);
338 let fw = personality::load_firmware(workspace);
339 let operator = personality::load_operator(workspace);
340 let directives = personality::load_directives(workspace);
341
342 let os_text =
343 personality::compose_identity_text(os.as_ref(), operator.as_ref(), directives.as_ref());
344 let firmware_text = personality::compose_firmware_text(fw.as_ref());
345
346 let (identity, voice) = match os {
347 Some(os) => (os.identity, os.voice),
348 None => (
349 OsIdentity {
350 name: String::new(),
351 version: "1.0".into(),
352 generated_by: "none".into(),
353 },
354 OsVoice::default(),
355 ),
356 };
357
358 Self {
359 os_text,
360 firmware_text,
361 identity,
362 voice,
363 }
364 }
365
366 pub fn empty() -> Self {
367 Self {
368 os_text: String::new(),
369 firmware_text: String::new(),
370 identity: OsIdentity {
371 name: String::new(),
372 version: "1.0".into(),
373 generated_by: "none".into(),
374 },
375 voice: OsVoice::default(),
376 }
377 }
378}
379
380#[derive(Debug)]
382pub struct InterviewSession {
383 pub history: Vec<roboticus_llm::format::UnifiedMessage>,
384 pub awaiting_confirmation: bool,
385 pub pending_output: Option<roboticus_core::personality::InterviewOutput>,
386 pub created_at: std::time::Instant,
387}
388
389impl Default for InterviewSession {
390 fn default() -> Self {
391 Self::new()
392 }
393}
394
395impl InterviewSession {
396 pub fn new() -> Self {
397 Self {
398 history: vec![roboticus_llm::format::UnifiedMessage {
399 role: "system".into(),
400 content: roboticus_agent::interview::build_interview_prompt(),
401 parts: None,
402 }],
403 awaiting_confirmation: false,
404 pending_output: None,
405 created_at: std::time::Instant::now(),
406 }
407 }
408}
409
410#[derive(Clone)]
411pub struct AppState {
412 pub db: Database,
413 pub config: Arc<RwLock<RoboticusConfig>>,
414 pub llm: Arc<RwLock<LlmService>>,
415 pub wallet: Arc<WalletService>,
416 pub a2a: Arc<RwLock<A2aProtocol>>,
417 pub personality: Arc<RwLock<PersonalityState>>,
418 pub hmac_secret: Arc<Vec<u8>>,
419 pub interviews: Arc<RwLock<HashMap<String, InterviewSession>>>,
420 pub plugins: Arc<PluginRegistry>,
421 pub policy_engine: Arc<PolicyEngine>,
422 pub browser: Arc<Browser>,
423 pub registry: Arc<SubagentRegistry>,
424 pub event_bus: EventBus,
425 pub channel_router: Arc<ChannelRouter>,
426 pub telegram: Option<Arc<TelegramAdapter>>,
427 pub whatsapp: Option<Arc<WhatsAppAdapter>>,
428 pub retriever: Arc<roboticus_agent::retrieval::MemoryRetriever>,
429 pub ann_index: roboticus_db::ann::AnnIndex,
430 pub tools: Arc<ToolRegistry>,
431 pub capabilities: Arc<CapabilityRegistry>,
433 pub approvals: Arc<ApprovalManager>,
434 pub discord: Option<Arc<DiscordAdapter>>,
435 pub signal: Option<Arc<SignalAdapter>>,
436 pub email: Option<Arc<EmailAdapter>>,
437 pub voice: Option<Arc<RwLock<VoicePipeline>>>,
438 pub media_service: Option<Arc<MediaService>>,
439 pub discovery: Arc<RwLock<roboticus_agent::discovery::DiscoveryRegistry>>,
440 pub devices: Arc<RwLock<roboticus_agent::device::DeviceManager>>,
441 pub mcp_clients: Arc<RwLock<roboticus_agent::mcp::McpClientManager>>,
442 pub mcp_server: Arc<RwLock<roboticus_agent::mcp::McpServerRegistry>>,
443 pub live_mcp: Arc<roboticus_agent::mcp::manager::McpConnectionManager>,
444 pub oauth: Arc<OAuthManager>,
445 pub keystore: Arc<roboticus_core::keystore::Keystore>,
446 pub obsidian: Option<Arc<RwLock<ObsidianVault>>>,
447 pub started_at: std::time::Instant,
448 pub config_path: Arc<PathBuf>,
449 pub config_apply_status: Arc<RwLock<ConfigApplyStatus>>,
450 pub pending_specialist_proposals: Arc<RwLock<HashMap<String, serde_json::Value>>>,
451 pub ws_tickets: crate::ws_ticket::TicketStore,
452 pub rate_limiter: crate::rate_limit::GlobalRateLimitLayer,
453 pub semantic_classifier: Arc<SemanticClassifier>,
456}
457
458impl AppState {
459 pub async fn resync_capabilities_from_tools(&self) {
461 if let Err(e) = self
462 .capabilities
463 .sync_from_tool_registry(Arc::clone(&self.tools))
464 .await
465 {
466 tracing::warn!(error = %e, "capability resync from tools reported errors");
467 }
468 }
469
470 pub async fn reload_personality(&self) {
471 let workspace = {
472 let config = self.config.read().await;
473 config.agent.workspace.clone()
474 };
475 let new_state = PersonalityState::from_workspace(&workspace);
476 tracing::info!(
477 personality = %new_state.identity.name,
478 generated_by = %new_state.identity.generated_by,
479 "Hot-reloaded personality from workspace"
480 );
481 *self.personality.write().await = new_state;
482 }
483}
484
485async fn json_error_layer(
493 req: axum::extract::Request,
494 next: middleware::Next,
495) -> axum::response::Response {
496 let response = next.run(req).await;
497 let status = response.status();
498
499 if !(status.is_client_error() || status.is_server_error()) {
500 return response;
501 }
502
503 let content_type = response
504 .headers()
505 .get(axum::http::header::CONTENT_TYPE)
506 .and_then(|v| v.to_str().ok())
507 .unwrap_or("");
508 if content_type.contains("application/problem+json")
510 || content_type.contains("application/json")
511 {
512 return response;
513 }
514
515 let code = response.status();
516 let (_parts, body) = response.into_parts();
517 let bytes = match axum::body::to_bytes(body, 8192).await {
518 Ok(b) => b,
519 Err(e) => {
520 tracing::warn!(error = %e, "failed to read response body for JSON wrapping");
521 axum::body::Bytes::new()
522 }
523 };
524 let original_text = String::from_utf8_lossy(&bytes);
525
526 let error_msg = if original_text.trim().is_empty() {
527 match code {
528 axum::http::StatusCode::METHOD_NOT_ALLOWED => "method not allowed".to_string(),
529 axum::http::StatusCode::NOT_FOUND => "not found".to_string(),
530 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE => {
531 "unsupported content type: expected application/json".to_string()
532 }
533 other => other.to_string(),
534 }
535 } else {
536 sanitize_error_message(original_text.trim())
537 };
538
539 let json_body = problem_json(code, &error_msg);
540 let body_bytes = serde_json::to_vec(&json_body)
541 .unwrap_or_else(|_| br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"internal error"}"#.to_vec());
542 let mut resp = axum::response::Response::new(axum::body::Body::from(body_bytes));
543 *resp.status_mut() = code;
544 resp.headers_mut().insert(
545 axum::http::header::CONTENT_TYPE,
546 axum::http::HeaderValue::from_static("application/problem+json"),
547 );
548 resp
549}
550
551async fn security_headers_layer(
562 req: axum::extract::Request,
563 next: middleware::Next,
564) -> axum::response::Response {
565 let mut response = next.run(req).await;
566 let headers = response.headers_mut();
567
568 let csp_name = axum::http::header::HeaderName::from_static("content-security-policy");
571 if !headers.contains_key(&csp_name) {
572 headers.insert(
573 csp_name,
574 axum::http::HeaderValue::from_static(
575 "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'",
576 ),
577 );
578 }
579 headers.insert(
580 axum::http::header::X_FRAME_OPTIONS,
581 axum::http::HeaderValue::from_static("DENY"),
582 );
583 headers.insert(
584 axum::http::header::X_CONTENT_TYPE_OPTIONS,
585 axum::http::HeaderValue::from_static("nosniff"),
586 );
587 response
588}
589
590async fn dashboard_redirect() -> axum::response::Redirect {
591 axum::response::Redirect::permanent("/")
592}
593
594pub fn build_router(state: AppState) -> Router {
597 use admin::{
598 a2a_hello, breaker_open, breaker_reset, breaker_status, browser_action, browser_start,
599 browser_status, browser_stop, change_agent_model, confirm_revenue_swap_task,
600 confirm_revenue_tax_task, create_service_quote, delete_provider_key, execute_plugin_tool,
601 fail_revenue_swap_task, fail_revenue_tax_task, fail_service_request,
602 fulfill_revenue_opportunity, fulfill_service_request, generate_deep_analysis, get_agents,
603 get_available_models, get_cache_stats, get_capacity_stats, get_config,
604 get_config_apply_status, get_config_capabilities, get_config_raw, get_costs,
605 get_efficiency, get_mcp_runtime, get_memory_analytics, get_overview_timeseries,
606 get_plugins, get_recommendations, get_revenue_opportunity, get_routing_dataset,
607 get_routing_diagnostics, get_runtime_surfaces, get_service_request, get_task_events,
608 get_throttle_stats, get_transactions, get_workspace_tasks, intake_micro_bounty_opportunity,
609 intake_oracle_feed_opportunity, intake_revenue_opportunity, keystore_status,
610 keystore_unlock, list_discovered_agents, list_paired_devices, list_revenue_opportunities,
611 list_revenue_swap_tasks, list_revenue_tax_tasks, list_services_catalog,
612 mcp_client_disconnect, mcp_client_discover, pair_device, plan_revenue_opportunity,
613 qualify_revenue_opportunity, reconcile_revenue_swap_task, reconcile_revenue_tax_task,
614 record_revenue_opportunity_feedback, register_discovered_agent, roster, run_routing_eval,
615 score_revenue_opportunity, set_provider_key, settle_revenue_opportunity, start_agent,
616 start_revenue_swap_task, start_revenue_tax_task, stop_agent, submit_revenue_swap_task,
617 submit_revenue_tax_task, toggle_plugin, unpair_device, update_config, update_config_raw,
618 verify_discovered_agent, verify_paired_device, verify_service_payment, wallet_address,
619 wallet_balance, workspace_state,
620 };
621 use agent::{agent_message, agent_message_stream, agent_status};
622 use channels::{
623 get_channels_status, get_dead_letters, get_integrations, replay_dead_letter, test_channel,
624 };
625 use cron::{
626 create_cron_job, delete_cron_job, get_cron_job, list_cron_jobs, list_cron_runs,
627 run_cron_job_now, update_cron_job,
628 };
629 use health::{get_logs, health};
630 use memory::{
631 get_episodic_memory, get_semantic_categories, get_semantic_memory, get_semantic_memory_all,
632 get_working_memory, get_working_memory_all, knowledge_ingest, memory_health, memory_search,
633 };
634 use sessions::{
635 analyze_session, analyze_turn, archive_session_handler, backfill_nicknames, create_session,
636 get_session, get_session_feedback, get_session_insights, get_turn, get_turn_context,
637 get_turn_feedback, get_turn_model_selection, get_turn_tips, get_turn_tools, list_messages,
638 list_model_selection_events, list_session_turns, list_sessions, post_message,
639 post_turn_feedback, put_turn_feedback,
640 };
641 use skills::{
642 audit_skills, catalog_activate, catalog_install, catalog_list, delete_skill, get_skill,
643 list_skills, reload_skills, toggle_skill, update_skill,
644 };
645 use subagents::{
646 create_sub_agent, delete_sub_agent, get_subagent_retirement_candidates, list_sub_agents,
647 retire_unused_subagents, toggle_sub_agent, update_sub_agent,
648 };
649 use themes::{install_catalog_theme, list_theme_catalog, list_themes};
650
651 Router::new()
652 .route("/", get(crate::dashboard::dashboard_handler))
653 .route("/dashboard", get(dashboard_redirect))
654 .route("/dashboard/", get(dashboard_redirect))
655 .route("/api/health", get(health))
656 .route("/health", get(health))
657 .route("/api/config", get(get_config).put(update_config))
658 .route(
659 "/api/config/raw",
660 get(get_config_raw).put(update_config_raw),
661 )
662 .route("/api/config/capabilities", get(get_config_capabilities))
663 .route("/api/config/status", get(get_config_apply_status))
664 .route(
665 "/api/providers/{name}/key",
666 put(set_provider_key).delete(delete_provider_key),
667 )
668 .route("/api/keystore/status", get(keystore_status))
669 .route("/api/keystore/unlock", post(keystore_unlock))
670 .route("/api/logs", get(get_logs))
671 .route("/api/sessions", get(list_sessions).post(create_session))
672 .route("/api/sessions/backfill-nicknames", post(backfill_nicknames))
673 .route("/api/sessions/{id}", get(get_session))
674 .route(
675 "/api/sessions/{id}/messages",
676 get(list_messages).post(post_message),
677 )
678 .route("/api/sessions/{id}/turns", get(list_session_turns))
679 .route("/api/sessions/{id}/archive", post(archive_session_handler))
680 .route("/api/sessions/{id}/insights", get(get_session_insights))
681 .route("/api/sessions/{id}/feedback", get(get_session_feedback))
682 .route("/api/turns/{id}", get(get_turn))
683 .route("/api/turns/{id}/context", get(get_turn_context))
684 .route(
685 "/api/turns/{id}/model-selection",
686 get(get_turn_model_selection),
687 )
688 .route("/api/turns/{id}/tools", get(get_turn_tools))
689 .route("/api/turns/{id}/tips", get(get_turn_tips))
690 .route("/api/models/selections", get(list_model_selection_events))
691 .route(
692 "/api/turns/{id}/feedback",
693 get(get_turn_feedback)
694 .post(post_turn_feedback)
695 .put(put_turn_feedback),
696 )
697 .route("/api/memory/working", get(get_working_memory_all))
698 .route("/api/memory/working/{session_id}", get(get_working_memory))
699 .route("/api/memory/episodic", get(get_episodic_memory))
700 .route("/api/memory/semantic", get(get_semantic_memory_all))
701 .route(
702 "/api/memory/semantic/categories",
703 get(get_semantic_categories),
704 )
705 .route("/api/memory/semantic/{category}", get(get_semantic_memory))
706 .route("/api/memory/search", get(memory_search))
707 .route("/api/memory/health", get(memory_health))
708 .route("/api/knowledge/ingest", post(knowledge_ingest))
709 .route("/api/cron/jobs", get(list_cron_jobs).post(create_cron_job))
710 .route("/api/cron/runs", get(list_cron_runs))
711 .route(
712 "/api/cron/jobs/{id}",
713 get(get_cron_job)
714 .put(update_cron_job)
715 .delete(delete_cron_job),
716 )
717 .route(
718 "/api/cron/jobs/{id}/run",
719 axum::routing::post(run_cron_job_now),
720 )
721 .route("/api/stats/costs", get(get_costs))
722 .route("/api/stats/timeseries", get(get_overview_timeseries))
723 .route("/api/stats/efficiency", get(get_efficiency))
724 .route("/api/stats/memory-analytics", get(get_memory_analytics))
725 .route("/api/recommendations", get(get_recommendations))
726 .route("/api/stats/transactions", get(get_transactions))
727 .route("/api/services/catalog", get(list_services_catalog))
728 .route("/api/services/quote", post(create_service_quote))
729 .route("/api/services/requests/{id}", get(get_service_request))
730 .route(
731 "/api/services/requests/{id}/payment/verify",
732 post(verify_service_payment),
733 )
734 .route(
735 "/api/services/requests/{id}/fulfill",
736 post(fulfill_service_request),
737 )
738 .route(
739 "/api/services/requests/{id}/fail",
740 post(fail_service_request),
741 )
742 .route(
743 "/api/services/opportunities/intake",
744 get(list_revenue_opportunities).post(intake_revenue_opportunity),
745 )
746 .route(
747 "/api/services/opportunities/adapters/micro-bounty/intake",
748 post(intake_micro_bounty_opportunity),
749 )
750 .route(
751 "/api/services/opportunities/adapters/oracle-feed/intake",
752 post(intake_oracle_feed_opportunity),
753 )
754 .route(
755 "/api/services/opportunities/{id}",
756 get(get_revenue_opportunity),
757 )
758 .route(
759 "/api/services/opportunities/{id}/score",
760 post(score_revenue_opportunity),
761 )
762 .route(
763 "/api/services/opportunities/{id}/qualify",
764 post(qualify_revenue_opportunity),
765 )
766 .route(
767 "/api/services/opportunities/{id}/feedback",
768 post(record_revenue_opportunity_feedback),
769 )
770 .route(
771 "/api/services/opportunities/{id}/plan",
772 post(plan_revenue_opportunity),
773 )
774 .route(
775 "/api/services/opportunities/{id}/fulfill",
776 post(fulfill_revenue_opportunity),
777 )
778 .route(
779 "/api/services/opportunities/{id}/settle",
780 post(settle_revenue_opportunity),
781 )
782 .route("/api/services/swaps", get(list_revenue_swap_tasks))
783 .route("/api/services/tax-payouts", get(list_revenue_tax_tasks))
784 .route(
785 "/api/services/swaps/{id}/start",
786 post(start_revenue_swap_task),
787 )
788 .route(
789 "/api/services/swaps/{id}/submit",
790 post(submit_revenue_swap_task),
791 )
792 .route(
793 "/api/services/swaps/{id}/reconcile",
794 post(reconcile_revenue_swap_task),
795 )
796 .route(
797 "/api/services/swaps/{id}/confirm",
798 post(confirm_revenue_swap_task),
799 )
800 .route(
801 "/api/services/swaps/{id}/fail",
802 post(fail_revenue_swap_task),
803 )
804 .route(
805 "/api/services/tax-payouts/{id}/start",
806 post(start_revenue_tax_task),
807 )
808 .route(
809 "/api/services/tax-payouts/{id}/submit",
810 post(submit_revenue_tax_task),
811 )
812 .route(
813 "/api/services/tax-payouts/{id}/reconcile",
814 post(reconcile_revenue_tax_task),
815 )
816 .route(
817 "/api/services/tax-payouts/{id}/confirm",
818 post(confirm_revenue_tax_task),
819 )
820 .route(
821 "/api/services/tax-payouts/{id}/fail",
822 post(fail_revenue_tax_task),
823 )
824 .route("/api/stats/cache", get(get_cache_stats))
825 .route("/api/stats/capacity", get(get_capacity_stats))
826 .route("/api/stats/throttle", get(get_throttle_stats))
827 .route("/api/models/available", get(get_available_models))
828 .route(
829 "/api/models/routing-diagnostics",
830 get(get_routing_diagnostics),
831 )
832 .route("/api/models/routing-dataset", get(get_routing_dataset))
833 .route("/api/models/routing-eval", post(run_routing_eval))
834 .route("/api/breaker/status", get(breaker_status))
835 .route("/api/breaker/open/{provider}", post(breaker_open))
836 .route("/api/breaker/reset/{provider}", post(breaker_reset))
837 .route("/api/agent/status", get(agent_status))
838 .route("/api/agent/message", post(agent_message))
839 .route("/api/agent/message/stream", post(agent_message_stream))
840 .route("/api/wallet/balance", get(wallet_balance))
841 .route("/api/wallet/address", get(wallet_address))
842 .route("/api/skills", get(list_skills))
843 .route("/api/skills/catalog", get(catalog_list))
844 .route("/api/skills/catalog/install", post(catalog_install))
845 .route("/api/skills/catalog/activate", post(catalog_activate))
846 .route("/api/skills/audit", get(audit_skills))
847 .route(
848 "/api/skills/{id}",
849 get(get_skill).put(update_skill).delete(delete_skill),
850 )
851 .route("/api/skills/reload", post(reload_skills))
852 .route("/api/skills/{id}/toggle", put(toggle_skill))
853 .route("/api/themes", get(list_themes))
854 .route("/api/themes/catalog", get(list_theme_catalog))
855 .route("/api/themes/catalog/install", post(install_catalog_theme))
856 .route("/api/plugins/catalog/install", post(catalog_install))
857 .route("/api/plugins", get(get_plugins))
858 .route("/api/plugins/{name}/toggle", put(toggle_plugin))
859 .route(
860 "/api/plugins/{name}/execute/{tool}",
861 post(execute_plugin_tool),
862 )
863 .route("/api/browser/status", get(browser_status))
864 .route("/api/browser/start", post(browser_start))
865 .route("/api/browser/stop", post(browser_stop))
866 .route("/api/browser/action", post(browser_action))
867 .route("/api/agents", get(get_agents))
868 .route("/api/agents/{id}/start", post(start_agent))
869 .route("/api/agents/{id}/stop", post(stop_agent))
870 .route(
871 "/api/subagents",
872 get(list_sub_agents).post(create_sub_agent),
873 )
874 .route(
875 "/api/subagents/retirement-candidates",
876 get(get_subagent_retirement_candidates),
877 )
878 .route(
879 "/api/subagents/retire-unused",
880 post(retire_unused_subagents),
881 )
882 .route(
883 "/api/subagents/{name}",
884 put(update_sub_agent).delete(delete_sub_agent),
885 )
886 .route("/api/subagents/{name}/toggle", put(toggle_sub_agent))
887 .route("/api/workspace/state", get(workspace_state))
888 .route("/api/workspace/tasks", get(get_workspace_tasks))
889 .route("/api/admin/task-events", get(get_task_events))
890 .route("/api/roster", get(roster))
891 .route("/api/roster/{name}/model", put(change_agent_model))
892 .route("/api/a2a/hello", post(a2a_hello))
893 .route("/api/channels/status", get(get_channels_status))
894 .route("/api/integrations", get(get_integrations))
895 .route("/api/channels/{platform}/test", post(test_channel))
896 .route("/api/channels/dead-letter", get(get_dead_letters))
897 .route(
898 "/api/channels/dead-letter/{id}/replay",
899 post(replay_dead_letter),
900 )
901 .route("/api/runtime/surfaces", get(get_runtime_surfaces))
902 .route(
903 "/api/runtime/discovery",
904 get(list_discovered_agents).post(register_discovered_agent),
905 )
906 .route(
907 "/api/runtime/discovery/{id}/verify",
908 post(verify_discovered_agent),
909 )
910 .route("/api/runtime/devices", get(list_paired_devices))
911 .route("/api/runtime/devices/pair", post(pair_device))
912 .route(
913 "/api/runtime/devices/{id}/verify",
914 post(verify_paired_device),
915 )
916 .route(
917 "/api/runtime/devices/{id}",
918 axum::routing::delete(unpair_device),
919 )
920 .route("/api/runtime/mcp", get(get_mcp_runtime))
921 .route(
922 "/api/runtime/mcp/clients/{name}/discover",
923 post(mcp_client_discover),
924 )
925 .route(
926 "/api/runtime/mcp/clients/{name}/disconnect",
927 post(mcp_client_disconnect),
928 )
929 .route("/api/mcp/servers", get(mcp::list_servers))
930 .route("/api/mcp/servers/{name}", get(mcp::get_server))
931 .route("/api/mcp/servers/{name}/test", post(mcp::test_server))
932 .route("/api/approvals", get(admin::list_approvals))
933 .route("/api/approvals/{id}/approve", post(admin::approve_request))
934 .route("/api/approvals/{id}/deny", post(admin::deny_request))
935 .route("/api/ws-ticket", post(admin::issue_ws_ticket))
936 .route("/api/interview/start", post(interview::start_interview))
937 .route("/api/interview/turn", post(interview::interview_turn))
938 .route("/api/interview/finish", post(interview::finish_interview))
939 .route("/api/audit/policy/{turn_id}", get(admin::get_policy_audit))
940 .route("/api/audit/tools/{turn_id}", get(admin::get_tool_audit))
941 .route("/api/traces/{turn_id}", get(traces::get_trace))
942 .route(
943 "/api/traces/{turn_id}/react",
944 get(traces::get_react_trace_handler),
945 )
946 .route("/api/traces/{turn_id}/export", get(traces::export_trace))
947 .route("/api/traces/{turn_id}/flow", get(traces::get_trace_flow))
948 .route("/api/observability/traces", get(observability::list_traces))
949 .route(
950 "/api/observability/traces/{turn_id}/waterfall",
951 get(observability::trace_waterfall),
952 )
953 .route(
954 "/api/observability/delegation/outcomes",
955 get(observability::delegation_outcomes),
956 )
957 .route(
958 "/api/observability/delegation/stats",
959 get(observability::delegation_stats),
960 )
961 .route(
962 "/favicon.ico",
963 get(|| async { axum::http::StatusCode::NO_CONTENT }),
964 )
965 .merge(
968 Router::new()
969 .route("/api/sessions/{id}/analyze", post(analyze_session))
970 .route("/api/turns/{id}/analyze", post(analyze_turn))
971 .route(
972 "/api/recommendations/generate",
973 post(generate_deep_analysis),
974 )
975 .layer(tower::limit::ConcurrencyLimitLayer::new(3))
976 .with_state(state.clone()),
977 )
978 .fallback(|| async { JsonError(axum::http::StatusCode::NOT_FOUND, "not found".into()) })
979 .layer(DefaultBodyLimit::max(1024 * 1024)) .layer(middleware::from_fn(json_error_layer))
981 .layer(middleware::from_fn(security_headers_layer))
982 .with_state(state)
984}
985
986pub fn build_public_router(state: AppState) -> Router {
989 use admin::agent_card;
990 use channels::{webhook_telegram, webhook_whatsapp, webhook_whatsapp_verify};
991
992 Router::new()
993 .route("/.well-known/agent.json", get(agent_card))
994 .route("/api/webhooks/telegram", post(webhook_telegram))
995 .route(
996 "/api/webhooks/whatsapp",
997 get(webhook_whatsapp_verify).post(webhook_whatsapp),
998 )
999 .layer(DefaultBodyLimit::max(1024 * 1024)) .with_state(state)
1001}
1002
1003pub fn build_mcp_router(state: &AppState, api_key: Option<String>) -> Router {
1014 use crate::auth::ApiKeyLayer;
1015 use rmcp::transport::streamable_http_server::{
1016 StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
1017 };
1018 use roboticus_agent::mcp_handler::{McpToolContext, RoboticusMcpHandler};
1019 use std::time::Duration;
1020
1021 let mcp_ctx = {
1022 let (workspace_root, agent_name, tool_allowed_paths, sandbox) = state
1023 .config
1024 .try_read()
1025 .map(|c| {
1026 (
1027 c.agent.workspace.clone(),
1028 c.agent.name.clone(),
1029 c.security.filesystem.tool_allowed_paths.clone(),
1030 roboticus_agent::tools::ToolSandboxSnapshot::from_config(
1031 &c.security.filesystem,
1032 &c.skills,
1033 ),
1034 )
1035 })
1036 .unwrap_or_else(|_| {
1037 (
1038 std::path::PathBuf::from("."),
1039 "roboticus".to_string(),
1040 Vec::new(),
1041 roboticus_agent::tools::ToolSandboxSnapshot::default(),
1042 )
1043 });
1044 McpToolContext {
1045 agent_id: "roboticus-mcp-gateway".to_string(),
1046 agent_name,
1047 workspace_root,
1048 tool_allowed_paths,
1049 sandbox,
1050 db: Some(state.db.clone()),
1051 }
1052 };
1053
1054 let handler = RoboticusMcpHandler::new(state.tools.clone(), mcp_ctx);
1055
1056 let config = StreamableHttpServerConfig {
1057 sse_keep_alive: Some(Duration::from_secs(15)),
1058 stateful_mode: true,
1059 ..Default::default()
1060 };
1061
1062 let service = StreamableHttpService::new(
1063 move || Ok(handler.clone()),
1064 Arc::new(LocalSessionManager::default()),
1065 config,
1066 );
1067
1068 Router::new()
1069 .nest_service("/mcp", service)
1070 .layer(ApiKeyLayer::new(api_key))
1071}
1072
1073pub use agent::{discord_poll_loop, email_poll_loop, signal_poll_loop, telegram_poll_loop};
1076pub use health::LogEntry;
1077
1078#[cfg(test)]
1081#[path = "tests.rs"]
1082mod tests;