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