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::{
24 Router, middleware,
25 routing::{get, post, put},
26};
27use tokio::sync::RwLock;
28
29use crate::config_runtime::ConfigApplyStatus;
30use roboticus_agent::policy::PolicyEngine;
31use roboticus_agent::subagents::SubagentRegistry;
32use roboticus_browser::Browser;
33use roboticus_channels::a2a::A2aProtocol;
34use roboticus_channels::router::ChannelRouter;
35use roboticus_channels::telegram::TelegramAdapter;
36use roboticus_channels::whatsapp::WhatsAppAdapter;
37use roboticus_core::RoboticusConfig;
38use roboticus_core::personality::{self, OsIdentity, OsVoice};
39use roboticus_db::Database;
40use roboticus_llm::LlmService;
41use roboticus_llm::OAuthManager;
42use roboticus_llm::semantic_classifier::SemanticClassifier;
43use roboticus_plugin_sdk::registry::PluginRegistry;
44use roboticus_wallet::WalletService;
45
46use roboticus_agent::approvals::ApprovalManager;
47use roboticus_agent::capability::CapabilityRegistry;
48use roboticus_agent::obsidian::ObsidianVault;
49use roboticus_agent::tools::ToolRegistry;
50use roboticus_channels::discord::DiscordAdapter;
51use roboticus_channels::email::EmailAdapter;
52use roboticus_channels::media::MediaService;
53use roboticus_channels::signal::SignalAdapter;
54use roboticus_channels::voice::VoicePipeline;
55
56use crate::ws::EventBus;
57
58#[derive(Debug)]
63pub(crate) struct JsonError(pub axum::http::StatusCode, pub String);
64
65impl axum::response::IntoResponse for JsonError {
66 fn into_response(self) -> axum::response::Response {
67 let body = serde_json::json!({ "error": self.1 });
68 (self.0, axum::Json(body)).into_response()
69 }
70}
71
72impl From<(axum::http::StatusCode, String)> for JsonError {
73 fn from((status, msg): (axum::http::StatusCode, String)) -> Self {
74 Self(status, msg)
75 }
76}
77
78pub(crate) fn bad_request(msg: impl std::fmt::Display) -> JsonError {
80 JsonError(axum::http::StatusCode::BAD_REQUEST, msg.to_string())
81}
82
83pub(crate) fn not_found(msg: impl std::fmt::Display) -> JsonError {
85 JsonError(axum::http::StatusCode::NOT_FOUND, msg.to_string())
86}
87
88pub(crate) fn sanitize_error_message(msg: &str) -> String {
99 let sanitized = msg.lines().next().unwrap_or(msg);
100
101 let sanitized = sanitized
102 .trim_start_matches("Database(\"")
103 .trim_end_matches("\")")
104 .trim_start_matches("Wallet(\"")
105 .trim_end_matches("\")");
106
107 let sensitive_prefixes = [
110 "at /", "called `Result::unwrap()` on an `Err` value:",
112 "SQLITE_", "Connection refused", "constraint failed", "no such table", "no such column", "UNIQUE constraint", "FOREIGN KEY constraint", "NOT NULL constraint", ];
121 let sanitized = {
122 let mut s = sanitized.to_string();
123 for prefix in &sensitive_prefixes {
124 if let Some(pos) = s.find(prefix) {
125 s.truncate(pos);
126 s.push_str("[details redacted]");
127 break;
128 }
129 }
130 s
131 };
132
133 if sanitized.len() > 200 {
134 let boundary = sanitized
135 .char_indices()
136 .map(|(i, _)| i)
137 .take_while(|&i| i <= 200)
138 .last()
139 .unwrap_or(0);
140 format!("{}...", &sanitized[..boundary])
141 } else {
142 sanitized
143 }
144}
145
146pub(crate) fn internal_err(e: &impl std::fmt::Display) -> JsonError {
148 tracing::error!(error = %e, "request failed");
149 JsonError(
150 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
151 sanitize_error_message(&e.to_string()),
152 )
153}
154
155const MAX_SHORT_FIELD: usize = 256;
159const MAX_LONG_FIELD: usize = 4096;
161
162pub(crate) fn validate_field(
164 field_name: &str,
165 value: &str,
166 max_len: usize,
167) -> Result<(), JsonError> {
168 if value.trim().is_empty() {
169 return Err(bad_request(format!("{field_name} must not be empty")));
170 }
171 if value.contains('\0') {
172 return Err(bad_request(format!(
173 "{field_name} must not contain null bytes"
174 )));
175 }
176 if value.len() > max_len {
177 return Err(bad_request(format!(
178 "{field_name} exceeds max length ({max_len})"
179 )));
180 }
181 Ok(())
182}
183
184pub(crate) fn validate_short(field_name: &str, value: &str) -> Result<(), JsonError> {
186 validate_field(field_name, value, MAX_SHORT_FIELD)
187}
188
189pub(crate) fn validate_long(field_name: &str, value: &str) -> Result<(), JsonError> {
191 validate_field(field_name, value, MAX_LONG_FIELD)
192}
193
194pub(crate) fn sanitize_html(input: &str) -> String {
196 input
197 .replace('&', "&")
198 .replace('<', "<")
199 .replace('>', ">")
200 .replace('"', """)
201 .replace('\'', "'")
202}
203
204const DEFAULT_PAGE_SIZE: i64 = 200;
208const MAX_PAGE_SIZE: i64 = 500;
210
211#[derive(Debug, serde::Deserialize)]
213pub(crate) struct PaginationQuery {
214 pub limit: Option<i64>,
215 pub offset: Option<i64>,
216}
217
218impl PaginationQuery {
219 pub fn resolve(&self) -> (i64, i64) {
221 let limit = self
222 .limit
223 .unwrap_or(DEFAULT_PAGE_SIZE)
224 .clamp(1, MAX_PAGE_SIZE);
225 let offset = self.offset.unwrap_or(0).max(0);
226 (limit, offset)
227 }
228}
229
230#[derive(Debug, Clone)]
234pub struct PersonalityState {
235 pub os_text: String,
236 pub firmware_text: String,
237 pub identity: OsIdentity,
238 pub voice: OsVoice,
239}
240
241impl PersonalityState {
242 pub fn from_workspace(workspace: &std::path::Path) -> Self {
243 let os = personality::load_os(workspace);
244 let fw = personality::load_firmware(workspace);
245 let operator = personality::load_operator(workspace);
246 let directives = personality::load_directives(workspace);
247
248 let os_text =
249 personality::compose_identity_text(os.as_ref(), operator.as_ref(), directives.as_ref());
250 let firmware_text = personality::compose_firmware_text(fw.as_ref());
251
252 let (identity, voice) = match os {
253 Some(os) => (os.identity, os.voice),
254 None => (
255 OsIdentity {
256 name: String::new(),
257 version: "1.0".into(),
258 generated_by: "none".into(),
259 },
260 OsVoice::default(),
261 ),
262 };
263
264 Self {
265 os_text,
266 firmware_text,
267 identity,
268 voice,
269 }
270 }
271
272 pub fn empty() -> Self {
273 Self {
274 os_text: String::new(),
275 firmware_text: String::new(),
276 identity: OsIdentity {
277 name: String::new(),
278 version: "1.0".into(),
279 generated_by: "none".into(),
280 },
281 voice: OsVoice::default(),
282 }
283 }
284}
285
286#[derive(Debug)]
288pub struct InterviewSession {
289 pub history: Vec<roboticus_llm::format::UnifiedMessage>,
290 pub awaiting_confirmation: bool,
291 pub pending_output: Option<roboticus_core::personality::InterviewOutput>,
292 pub created_at: std::time::Instant,
293}
294
295impl Default for InterviewSession {
296 fn default() -> Self {
297 Self::new()
298 }
299}
300
301impl InterviewSession {
302 pub fn new() -> Self {
303 Self {
304 history: vec![roboticus_llm::format::UnifiedMessage {
305 role: "system".into(),
306 content: roboticus_agent::interview::build_interview_prompt(),
307 parts: None,
308 }],
309 awaiting_confirmation: false,
310 pending_output: None,
311 created_at: std::time::Instant::now(),
312 }
313 }
314}
315
316#[derive(Clone)]
317pub struct AppState {
318 pub db: Database,
319 pub config: Arc<RwLock<RoboticusConfig>>,
320 pub llm: Arc<RwLock<LlmService>>,
321 pub wallet: Arc<WalletService>,
322 pub a2a: Arc<RwLock<A2aProtocol>>,
323 pub personality: Arc<RwLock<PersonalityState>>,
324 pub hmac_secret: Arc<Vec<u8>>,
325 pub interviews: Arc<RwLock<HashMap<String, InterviewSession>>>,
326 pub plugins: Arc<PluginRegistry>,
327 pub policy_engine: Arc<PolicyEngine>,
328 pub browser: Arc<Browser>,
329 pub registry: Arc<SubagentRegistry>,
330 pub event_bus: EventBus,
331 pub channel_router: Arc<ChannelRouter>,
332 pub telegram: Option<Arc<TelegramAdapter>>,
333 pub whatsapp: Option<Arc<WhatsAppAdapter>>,
334 pub retriever: Arc<roboticus_agent::retrieval::MemoryRetriever>,
335 pub ann_index: roboticus_db::ann::AnnIndex,
336 pub tools: Arc<ToolRegistry>,
337 pub capabilities: Arc<CapabilityRegistry>,
339 pub approvals: Arc<ApprovalManager>,
340 pub discord: Option<Arc<DiscordAdapter>>,
341 pub signal: Option<Arc<SignalAdapter>>,
342 pub email: Option<Arc<EmailAdapter>>,
343 pub voice: Option<Arc<RwLock<VoicePipeline>>>,
344 pub media_service: Option<Arc<MediaService>>,
345 pub discovery: Arc<RwLock<roboticus_agent::discovery::DiscoveryRegistry>>,
346 pub devices: Arc<RwLock<roboticus_agent::device::DeviceManager>>,
347 pub mcp_clients: Arc<RwLock<roboticus_agent::mcp::McpClientManager>>,
348 pub mcp_server: Arc<RwLock<roboticus_agent::mcp::McpServerRegistry>>,
349 pub live_mcp: Arc<roboticus_agent::mcp::manager::McpConnectionManager>,
350 pub oauth: Arc<OAuthManager>,
351 pub keystore: Arc<roboticus_core::keystore::Keystore>,
352 pub obsidian: Option<Arc<RwLock<ObsidianVault>>>,
353 pub started_at: std::time::Instant,
354 pub config_path: Arc<PathBuf>,
355 pub config_apply_status: Arc<RwLock<ConfigApplyStatus>>,
356 pub pending_specialist_proposals: Arc<RwLock<HashMap<String, serde_json::Value>>>,
357 pub ws_tickets: crate::ws_ticket::TicketStore,
358 pub rate_limiter: crate::rate_limit::GlobalRateLimitLayer,
359 pub semantic_classifier: Arc<SemanticClassifier>,
362}
363
364impl AppState {
365 pub async fn resync_capabilities_from_tools(&self) {
367 if let Err(e) = self
368 .capabilities
369 .sync_from_tool_registry(Arc::clone(&self.tools))
370 .await
371 {
372 tracing::warn!(error = %e, "capability resync from tools reported errors");
373 }
374 }
375
376 pub async fn reload_personality(&self) {
377 let workspace = {
378 let config = self.config.read().await;
379 config.agent.workspace.clone()
380 };
381 let new_state = PersonalityState::from_workspace(&workspace);
382 tracing::info!(
383 personality = %new_state.identity.name,
384 generated_by = %new_state.identity.generated_by,
385 "Hot-reloaded personality from workspace"
386 );
387 *self.personality.write().await = new_state;
388 }
389}
390
391async fn json_error_layer(
399 req: axum::extract::Request,
400 next: middleware::Next,
401) -> axum::response::Response {
402 let response = next.run(req).await;
403 let status = response.status();
404
405 if !(status.is_client_error() || status.is_server_error()) {
406 return response;
407 }
408
409 let is_json = response
410 .headers()
411 .get(axum::http::header::CONTENT_TYPE)
412 .and_then(|v| v.to_str().ok())
413 .is_some_and(|ct| ct.contains("application/json"));
414 if is_json {
415 return response;
416 }
417
418 let code = response.status();
419 let (_parts, body) = response.into_parts();
420 let bytes = match axum::body::to_bytes(body, 8192).await {
421 Ok(b) => b,
422 Err(e) => {
423 tracing::warn!(error = %e, "failed to read response body for JSON wrapping");
424 axum::body::Bytes::new()
425 }
426 };
427 let original_text = String::from_utf8_lossy(&bytes);
428
429 let error_msg = if original_text.trim().is_empty() {
430 match code {
431 axum::http::StatusCode::METHOD_NOT_ALLOWED => "method not allowed".to_string(),
432 axum::http::StatusCode::NOT_FOUND => "not found".to_string(),
433 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE => {
434 "unsupported content type: expected application/json".to_string()
435 }
436 other => other.to_string(),
437 }
438 } else {
439 sanitize_error_message(original_text.trim())
440 };
441
442 let json_body = serde_json::json!({ "error": error_msg });
443 let body_bytes = serde_json::to_vec(&json_body)
444 .unwrap_or_else(|_| br#"{"error":"internal error"}"#.to_vec());
445 let mut resp = axum::response::Response::new(axum::body::Body::from(body_bytes));
446 *resp.status_mut() = code;
447 resp.headers_mut().insert(
448 axum::http::header::CONTENT_TYPE,
449 axum::http::HeaderValue::from_static("application/json"),
450 );
451 resp
452}
453
454async fn security_headers_layer(
465 req: axum::extract::Request,
466 next: middleware::Next,
467) -> axum::response::Response {
468 let mut response = next.run(req).await;
469 let headers = response.headers_mut();
470
471 let csp_name = axum::http::header::HeaderName::from_static("content-security-policy");
474 if !headers.contains_key(&csp_name) {
475 headers.insert(
476 csp_name,
477 axum::http::HeaderValue::from_static(
478 "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'",
479 ),
480 );
481 }
482 headers.insert(
483 axum::http::header::X_FRAME_OPTIONS,
484 axum::http::HeaderValue::from_static("DENY"),
485 );
486 headers.insert(
487 axum::http::header::X_CONTENT_TYPE_OPTIONS,
488 axum::http::HeaderValue::from_static("nosniff"),
489 );
490 response
491}
492
493async fn dashboard_redirect() -> axum::response::Redirect {
494 axum::response::Redirect::permanent("/")
495}
496
497pub fn build_router(state: AppState) -> Router {
500 use admin::{
501 a2a_hello, breaker_open, breaker_reset, breaker_status, browser_action, browser_start,
502 browser_status, browser_stop, change_agent_model, confirm_revenue_swap_task,
503 confirm_revenue_tax_task, create_service_quote, delete_provider_key, execute_plugin_tool,
504 fail_revenue_swap_task, fail_revenue_tax_task, fail_service_request,
505 fulfill_revenue_opportunity, fulfill_service_request, generate_deep_analysis, get_agents,
506 get_available_models, get_cache_stats, get_capacity_stats, get_config,
507 get_config_apply_status, get_config_capabilities, get_config_raw, get_costs,
508 get_efficiency, get_mcp_runtime, get_memory_analytics, get_overview_timeseries,
509 get_plugins, get_recommendations, get_revenue_opportunity, get_routing_dataset,
510 get_routing_diagnostics, get_runtime_surfaces, get_service_request, get_task_events,
511 get_throttle_stats, get_transactions, get_workspace_tasks, intake_micro_bounty_opportunity,
512 intake_oracle_feed_opportunity, intake_revenue_opportunity, list_discovered_agents,
513 list_paired_devices, list_revenue_opportunities, list_revenue_swap_tasks,
514 list_revenue_tax_tasks, list_services_catalog, mcp_client_disconnect, mcp_client_discover,
515 pair_device, plan_revenue_opportunity, qualify_revenue_opportunity,
516 reconcile_revenue_swap_task, reconcile_revenue_tax_task,
517 record_revenue_opportunity_feedback, register_discovered_agent, roster, run_routing_eval,
518 score_revenue_opportunity, set_provider_key, settle_revenue_opportunity, start_agent,
519 start_revenue_swap_task, start_revenue_tax_task, stop_agent, submit_revenue_swap_task,
520 submit_revenue_tax_task, toggle_plugin, unpair_device, update_config, update_config_raw,
521 verify_discovered_agent, verify_paired_device, verify_service_payment, wallet_address,
522 wallet_balance, workspace_state,
523 };
524 use agent::{agent_message, agent_message_stream, agent_status};
525 use channels::{get_channels_status, get_dead_letters, replay_dead_letter, test_channel};
526 use cron::{
527 create_cron_job, delete_cron_job, get_cron_job, list_cron_jobs, list_cron_runs,
528 run_cron_job_now, update_cron_job,
529 };
530 use health::{get_logs, health};
531 use memory::{
532 get_episodic_memory, get_semantic_categories, get_semantic_memory, get_semantic_memory_all,
533 get_working_memory, get_working_memory_all, knowledge_ingest, memory_health, memory_search,
534 };
535 use sessions::{
536 analyze_session, analyze_turn, archive_session_handler, backfill_nicknames, create_session,
537 get_session, get_session_feedback, get_session_insights, get_turn, get_turn_context,
538 get_turn_feedback, get_turn_model_selection, get_turn_tips, get_turn_tools, list_messages,
539 list_model_selection_events, list_session_turns, list_sessions, post_message,
540 post_turn_feedback, put_turn_feedback,
541 };
542 use skills::{
543 audit_skills, catalog_activate, catalog_install, catalog_list, delete_skill, get_skill,
544 list_skills, reload_skills, toggle_skill, update_skill,
545 };
546 use subagents::{
547 create_sub_agent, delete_sub_agent, get_subagent_retirement_candidates, list_sub_agents,
548 retire_unused_subagents, toggle_sub_agent, update_sub_agent,
549 };
550 use themes::list_themes;
551
552 Router::new()
553 .route("/", get(crate::dashboard::dashboard_handler))
554 .route("/dashboard", get(dashboard_redirect))
555 .route("/dashboard/", get(dashboard_redirect))
556 .route("/api/health", get(health))
557 .route("/health", get(health))
558 .route("/api/config", get(get_config).put(update_config))
559 .route(
560 "/api/config/raw",
561 get(get_config_raw).put(update_config_raw),
562 )
563 .route("/api/config/capabilities", get(get_config_capabilities))
564 .route("/api/config/status", get(get_config_apply_status))
565 .route(
566 "/api/providers/{name}/key",
567 put(set_provider_key).delete(delete_provider_key),
568 )
569 .route("/api/logs", get(get_logs))
570 .route("/api/sessions", get(list_sessions).post(create_session))
571 .route("/api/sessions/backfill-nicknames", post(backfill_nicknames))
572 .route("/api/sessions/{id}", get(get_session))
573 .route(
574 "/api/sessions/{id}/messages",
575 get(list_messages).post(post_message),
576 )
577 .route("/api/sessions/{id}/turns", get(list_session_turns))
578 .route("/api/sessions/{id}/archive", post(archive_session_handler))
579 .route("/api/sessions/{id}/insights", get(get_session_insights))
580 .route("/api/sessions/{id}/feedback", get(get_session_feedback))
581 .route("/api/turns/{id}", get(get_turn))
582 .route("/api/turns/{id}/context", get(get_turn_context))
583 .route(
584 "/api/turns/{id}/model-selection",
585 get(get_turn_model_selection),
586 )
587 .route("/api/turns/{id}/tools", get(get_turn_tools))
588 .route("/api/turns/{id}/tips", get(get_turn_tips))
589 .route("/api/models/selections", get(list_model_selection_events))
590 .route(
591 "/api/turns/{id}/feedback",
592 get(get_turn_feedback)
593 .post(post_turn_feedback)
594 .put(put_turn_feedback),
595 )
596 .route("/api/memory/working", get(get_working_memory_all))
597 .route("/api/memory/working/{session_id}", get(get_working_memory))
598 .route("/api/memory/episodic", get(get_episodic_memory))
599 .route("/api/memory/semantic", get(get_semantic_memory_all))
600 .route(
601 "/api/memory/semantic/categories",
602 get(get_semantic_categories),
603 )
604 .route("/api/memory/semantic/{category}", get(get_semantic_memory))
605 .route("/api/memory/search", get(memory_search))
606 .route("/api/memory/health", get(memory_health))
607 .route("/api/knowledge/ingest", post(knowledge_ingest))
608 .route("/api/cron/jobs", get(list_cron_jobs).post(create_cron_job))
609 .route("/api/cron/runs", get(list_cron_runs))
610 .route(
611 "/api/cron/jobs/{id}",
612 get(get_cron_job)
613 .put(update_cron_job)
614 .delete(delete_cron_job),
615 )
616 .route(
617 "/api/cron/jobs/{id}/run",
618 axum::routing::post(run_cron_job_now),
619 )
620 .route("/api/stats/costs", get(get_costs))
621 .route("/api/stats/timeseries", get(get_overview_timeseries))
622 .route("/api/stats/efficiency", get(get_efficiency))
623 .route("/api/stats/memory-analytics", get(get_memory_analytics))
624 .route("/api/recommendations", get(get_recommendations))
625 .route("/api/stats/transactions", get(get_transactions))
626 .route("/api/services/catalog", get(list_services_catalog))
627 .route("/api/services/quote", post(create_service_quote))
628 .route("/api/services/requests/{id}", get(get_service_request))
629 .route(
630 "/api/services/requests/{id}/payment/verify",
631 post(verify_service_payment),
632 )
633 .route(
634 "/api/services/requests/{id}/fulfill",
635 post(fulfill_service_request),
636 )
637 .route(
638 "/api/services/requests/{id}/fail",
639 post(fail_service_request),
640 )
641 .route(
642 "/api/services/opportunities/intake",
643 get(list_revenue_opportunities).post(intake_revenue_opportunity),
644 )
645 .route(
646 "/api/services/opportunities/adapters/micro-bounty/intake",
647 post(intake_micro_bounty_opportunity),
648 )
649 .route(
650 "/api/services/opportunities/adapters/oracle-feed/intake",
651 post(intake_oracle_feed_opportunity),
652 )
653 .route(
654 "/api/services/opportunities/{id}",
655 get(get_revenue_opportunity),
656 )
657 .route(
658 "/api/services/opportunities/{id}/score",
659 post(score_revenue_opportunity),
660 )
661 .route(
662 "/api/services/opportunities/{id}/qualify",
663 post(qualify_revenue_opportunity),
664 )
665 .route(
666 "/api/services/opportunities/{id}/feedback",
667 post(record_revenue_opportunity_feedback),
668 )
669 .route(
670 "/api/services/opportunities/{id}/plan",
671 post(plan_revenue_opportunity),
672 )
673 .route(
674 "/api/services/opportunities/{id}/fulfill",
675 post(fulfill_revenue_opportunity),
676 )
677 .route(
678 "/api/services/opportunities/{id}/settle",
679 post(settle_revenue_opportunity),
680 )
681 .route("/api/services/swaps", get(list_revenue_swap_tasks))
682 .route("/api/services/tax-payouts", get(list_revenue_tax_tasks))
683 .route(
684 "/api/services/swaps/{id}/start",
685 post(start_revenue_swap_task),
686 )
687 .route(
688 "/api/services/swaps/{id}/submit",
689 post(submit_revenue_swap_task),
690 )
691 .route(
692 "/api/services/swaps/{id}/reconcile",
693 post(reconcile_revenue_swap_task),
694 )
695 .route(
696 "/api/services/swaps/{id}/confirm",
697 post(confirm_revenue_swap_task),
698 )
699 .route(
700 "/api/services/swaps/{id}/fail",
701 post(fail_revenue_swap_task),
702 )
703 .route(
704 "/api/services/tax-payouts/{id}/start",
705 post(start_revenue_tax_task),
706 )
707 .route(
708 "/api/services/tax-payouts/{id}/submit",
709 post(submit_revenue_tax_task),
710 )
711 .route(
712 "/api/services/tax-payouts/{id}/reconcile",
713 post(reconcile_revenue_tax_task),
714 )
715 .route(
716 "/api/services/tax-payouts/{id}/confirm",
717 post(confirm_revenue_tax_task),
718 )
719 .route(
720 "/api/services/tax-payouts/{id}/fail",
721 post(fail_revenue_tax_task),
722 )
723 .route("/api/stats/cache", get(get_cache_stats))
724 .route("/api/stats/capacity", get(get_capacity_stats))
725 .route("/api/stats/throttle", get(get_throttle_stats))
726 .route("/api/models/available", get(get_available_models))
727 .route(
728 "/api/models/routing-diagnostics",
729 get(get_routing_diagnostics),
730 )
731 .route("/api/models/routing-dataset", get(get_routing_dataset))
732 .route("/api/models/routing-eval", post(run_routing_eval))
733 .route("/api/breaker/status", get(breaker_status))
734 .route("/api/breaker/open/{provider}", post(breaker_open))
735 .route("/api/breaker/reset/{provider}", post(breaker_reset))
736 .route("/api/agent/status", get(agent_status))
737 .route("/api/agent/message", post(agent_message))
738 .route("/api/agent/message/stream", post(agent_message_stream))
739 .route("/api/wallet/balance", get(wallet_balance))
740 .route("/api/wallet/address", get(wallet_address))
741 .route("/api/skills", get(list_skills))
742 .route("/api/skills/catalog", get(catalog_list))
743 .route("/api/skills/catalog/install", post(catalog_install))
744 .route("/api/skills/catalog/activate", post(catalog_activate))
745 .route("/api/skills/audit", get(audit_skills))
746 .route(
747 "/api/skills/{id}",
748 get(get_skill).put(update_skill).delete(delete_skill),
749 )
750 .route("/api/skills/reload", post(reload_skills))
751 .route("/api/skills/{id}/toggle", put(toggle_skill))
752 .route("/api/themes", get(list_themes))
753 .route("/api/plugins/catalog/install", post(catalog_install))
754 .route("/api/plugins", get(get_plugins))
755 .route("/api/plugins/{name}/toggle", put(toggle_plugin))
756 .route(
757 "/api/plugins/{name}/execute/{tool}",
758 post(execute_plugin_tool),
759 )
760 .route("/api/browser/status", get(browser_status))
761 .route("/api/browser/start", post(browser_start))
762 .route("/api/browser/stop", post(browser_stop))
763 .route("/api/browser/action", post(browser_action))
764 .route("/api/agents", get(get_agents))
765 .route("/api/agents/{id}/start", post(start_agent))
766 .route("/api/agents/{id}/stop", post(stop_agent))
767 .route(
768 "/api/subagents",
769 get(list_sub_agents).post(create_sub_agent),
770 )
771 .route(
772 "/api/subagents/retirement-candidates",
773 get(get_subagent_retirement_candidates),
774 )
775 .route(
776 "/api/subagents/retire-unused",
777 post(retire_unused_subagents),
778 )
779 .route(
780 "/api/subagents/{name}",
781 put(update_sub_agent).delete(delete_sub_agent),
782 )
783 .route("/api/subagents/{name}/toggle", put(toggle_sub_agent))
784 .route("/api/workspace/state", get(workspace_state))
785 .route("/api/workspace/tasks", get(get_workspace_tasks))
786 .route("/api/admin/task-events", get(get_task_events))
787 .route("/api/roster", get(roster))
788 .route("/api/roster/{name}/model", put(change_agent_model))
789 .route("/api/a2a/hello", post(a2a_hello))
790 .route("/api/channels/status", get(get_channels_status))
791 .route("/api/channels/{platform}/test", post(test_channel))
792 .route("/api/channels/dead-letter", get(get_dead_letters))
793 .route(
794 "/api/channels/dead-letter/{id}/replay",
795 post(replay_dead_letter),
796 )
797 .route("/api/runtime/surfaces", get(get_runtime_surfaces))
798 .route(
799 "/api/runtime/discovery",
800 get(list_discovered_agents).post(register_discovered_agent),
801 )
802 .route(
803 "/api/runtime/discovery/{id}/verify",
804 post(verify_discovered_agent),
805 )
806 .route("/api/runtime/devices", get(list_paired_devices))
807 .route("/api/runtime/devices/pair", post(pair_device))
808 .route(
809 "/api/runtime/devices/{id}/verify",
810 post(verify_paired_device),
811 )
812 .route(
813 "/api/runtime/devices/{id}",
814 axum::routing::delete(unpair_device),
815 )
816 .route("/api/runtime/mcp", get(get_mcp_runtime))
817 .route(
818 "/api/runtime/mcp/clients/{name}/discover",
819 post(mcp_client_discover),
820 )
821 .route(
822 "/api/runtime/mcp/clients/{name}/disconnect",
823 post(mcp_client_disconnect),
824 )
825 .route("/api/mcp/servers", get(mcp::list_servers))
826 .route("/api/mcp/servers/{name}", get(mcp::get_server))
827 .route("/api/mcp/servers/{name}/test", post(mcp::test_server))
828 .route("/api/approvals", get(admin::list_approvals))
829 .route("/api/approvals/{id}/approve", post(admin::approve_request))
830 .route("/api/approvals/{id}/deny", post(admin::deny_request))
831 .route("/api/ws-ticket", post(admin::issue_ws_ticket))
832 .route("/api/interview/start", post(interview::start_interview))
833 .route("/api/interview/turn", post(interview::interview_turn))
834 .route("/api/interview/finish", post(interview::finish_interview))
835 .route("/api/audit/policy/{turn_id}", get(admin::get_policy_audit))
836 .route("/api/audit/tools/{turn_id}", get(admin::get_tool_audit))
837 .route("/api/traces/{turn_id}", get(traces::get_trace))
838 .route(
839 "/api/traces/{turn_id}/react",
840 get(traces::get_react_trace_handler),
841 )
842 .route("/api/observability/traces", get(observability::list_traces))
843 .route(
844 "/api/observability/traces/{turn_id}/waterfall",
845 get(observability::trace_waterfall),
846 )
847 .route(
848 "/api/observability/delegation/outcomes",
849 get(observability::delegation_outcomes),
850 )
851 .route(
852 "/api/observability/delegation/stats",
853 get(observability::delegation_stats),
854 )
855 .route(
856 "/favicon.ico",
857 get(|| async { axum::http::StatusCode::NO_CONTENT }),
858 )
859 .merge(
862 Router::new()
863 .route("/api/sessions/{id}/analyze", post(analyze_session))
864 .route("/api/turns/{id}/analyze", post(analyze_turn))
865 .route(
866 "/api/recommendations/generate",
867 post(generate_deep_analysis),
868 )
869 .layer(tower::limit::ConcurrencyLimitLayer::new(3))
870 .with_state(state.clone()),
871 )
872 .fallback(|| async { JsonError(axum::http::StatusCode::NOT_FOUND, "not found".into()) })
873 .layer(DefaultBodyLimit::max(1024 * 1024)) .layer(middleware::from_fn(json_error_layer))
875 .layer(middleware::from_fn(security_headers_layer))
876 .with_state(state)
877}
878
879pub fn build_public_router(state: AppState) -> Router {
882 use admin::agent_card;
883 use channels::{webhook_telegram, webhook_whatsapp, webhook_whatsapp_verify};
884
885 Router::new()
886 .route("/.well-known/agent.json", get(agent_card))
887 .route("/api/webhooks/telegram", post(webhook_telegram))
888 .route(
889 "/api/webhooks/whatsapp",
890 get(webhook_whatsapp_verify).post(webhook_whatsapp),
891 )
892 .layer(DefaultBodyLimit::max(1024 * 1024)) .with_state(state)
894}
895
896pub fn build_mcp_router(state: &AppState, api_key: Option<String>) -> Router {
907 use crate::auth::ApiKeyLayer;
908 use rmcp::transport::streamable_http_server::{
909 StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
910 };
911 use roboticus_agent::mcp_handler::{McpToolContext, RoboticusMcpHandler};
912 use std::time::Duration;
913
914 let mcp_ctx = {
915 let (workspace_root, agent_name, tool_allowed_paths, sandbox) = state
916 .config
917 .try_read()
918 .map(|c| {
919 (
920 c.agent.workspace.clone(),
921 c.agent.name.clone(),
922 c.security.filesystem.tool_allowed_paths.clone(),
923 roboticus_agent::tools::ToolSandboxSnapshot::from_config(
924 &c.security.filesystem,
925 &c.skills,
926 ),
927 )
928 })
929 .unwrap_or_else(|_| {
930 (
931 std::path::PathBuf::from("."),
932 "roboticus".to_string(),
933 Vec::new(),
934 roboticus_agent::tools::ToolSandboxSnapshot::default(),
935 )
936 });
937 McpToolContext {
938 agent_id: "roboticus-mcp-gateway".to_string(),
939 agent_name,
940 workspace_root,
941 tool_allowed_paths,
942 sandbox,
943 db: Some(state.db.clone()),
944 }
945 };
946
947 let handler = RoboticusMcpHandler::new(state.tools.clone(), mcp_ctx);
948
949 let config = StreamableHttpServerConfig {
950 sse_keep_alive: Some(Duration::from_secs(15)),
951 stateful_mode: true,
952 ..Default::default()
953 };
954
955 let service = StreamableHttpService::new(
956 move || Ok(handler.clone()),
957 Arc::new(LocalSessionManager::default()),
958 config,
959 );
960
961 Router::new()
962 .nest_service("/mcp", service)
963 .layer(ApiKeyLayer::new(api_key))
964}
965
966pub use agent::{discord_poll_loop, email_poll_loop, signal_poll_loop, telegram_poll_loop};
969pub use health::LogEntry;
970
971#[cfg(test)]
974#[path = "tests.rs"]
975mod tests;