Skip to main content

feagi_api/endpoints/
agent.rs.disabled

1// Copyright 2025 Neuraville Inc.
2// Licensed under the Apache License, Version 2.0
3
4//! Agent endpoints - exact port from Python `/v1/agent/*` routes
5//!
6//! These endpoints match the Python implementation at:
7//! feagi-py/feagi/api/v1/feagi_agent.py
8
9use std::collections::HashMap;
10
11use crate::common::ApiState;
12use crate::common::{ApiError, ApiResult, Json, Path, Query, State};
13use crate::v1::agent_dtos::*;
14use feagi_services::traits::agent_service::HeartbeatRequest as ServiceHeartbeatRequest;
15use feagi_services::traits::agent_service::ManualStimulationMode;
16use tracing::{info, warn};
17
18#[cfg(feature = "feagi-agent")]
19use crate::common::agent_registration::auto_create_cortical_areas_from_device_registrations as auto_create_cortical_areas_shared;
20#[cfg(feature = "feagi-agent")]
21use feagi_agent::{AgentCapabilities as RegistrationCapabilities, AuthToken};
22#[cfg(feature = "feagi-agent")]
23use feagi_io::AgentID;
24#[cfg(feature = "feagi-agent")]
25#[allow(dead_code)]
26fn parse_auth_token(request: &AgentRegistrationRequest) -> ApiResult<AuthToken> {
27    let token_b64 = request
28        .auth_token
29        .as_deref()
30        .ok_or_else(|| ApiError::invalid_input("Missing auth_token for registration"))?;
31    AuthToken::from_base64(token_b64).ok_or_else(|| {
32        ApiError::invalid_input("Invalid auth_token (expected base64 32-byte token)")
33    })
34}
35
36#[cfg(feature = "feagi-agent")]
37#[allow(dead_code)]
38fn derive_capabilities_from_device_registrations(
39    device_registrations: &serde_json::Value,
40) -> ApiResult<Vec<RegistrationCapabilities>> {
41    let obj = device_registrations
42        .as_object()
43        .ok_or_else(|| ApiError::invalid_input("device_registrations must be a JSON object"))?;
44
45    let input_units = obj
46        .get("input_units_and_encoder_properties")
47        .and_then(|v| v.as_object());
48    let output_units = obj
49        .get("output_units_and_decoder_properties")
50        .and_then(|v| v.as_object());
51    let feedbacks = obj.get("feedbacks").and_then(|v| v.as_object());
52
53    let mut capabilities = Vec::new();
54    if input_units.map(|m| !m.is_empty()).unwrap_or(false) {
55        capabilities.push(RegistrationCapabilities::SendSensorData);
56    }
57    if output_units.map(|m| !m.is_empty()).unwrap_or(false) {
58        capabilities.push(RegistrationCapabilities::ReceiveMotorData);
59    }
60    if feedbacks.map(|m| !m.is_empty()).unwrap_or(false) {
61        capabilities.push(RegistrationCapabilities::ReceiveNeuronVisualizations);
62    }
63
64    if capabilities.is_empty() {
65        return Err(ApiError::invalid_input(
66            "device_registrations does not declare any input/output/feedback units",
67        ));
68    }
69
70    Ok(capabilities)
71}
72
73/// Derive capabilities for visualization-only agents (no device_registrations).
74/// Requires `capabilities.visualization` with valid `rate_hz`. Auth is still required by caller.
75#[cfg(feature = "feagi-agent")]
76#[allow(dead_code)]
77fn derive_capabilities_from_visualization_capability(
78    request: &AgentRegistrationRequest,
79) -> ApiResult<Vec<RegistrationCapabilities>> {
80    let viz = request
81        .capabilities
82        .get("visualization")
83        .and_then(|v| v.as_object())
84        .ok_or_else(|| {
85            ApiError::invalid_input(
86                "visualization-only registration requires capabilities.visualization object",
87            )
88        })?;
89    let rate_hz = viz.get("rate_hz").and_then(|v| v.as_f64()).ok_or_else(|| {
90        ApiError::invalid_input("capabilities.visualization must include rate_hz (number > 0)")
91    })?;
92    if rate_hz <= 0.0 {
93        return Err(ApiError::invalid_input(
94            "capabilities.visualization.rate_hz must be > 0",
95        ));
96    }
97    Ok(vec![RegistrationCapabilities::ReceiveNeuronVisualizations])
98}
99
100#[cfg(feature = "feagi-agent")]
101#[allow(dead_code)]
102fn parse_capability_rate_hz(
103    capabilities: &HashMap<String, serde_json::Value>,
104    capability_key: &str,
105) -> ApiResult<Option<f64>> {
106    let Some(capability_value) = capabilities.get(capability_key) else {
107        return Ok(None);
108    };
109
110    let Some(rate_value) = capability_value.get("rate_hz") else {
111        return Ok(None);
112    };
113
114    let rate_hz = rate_value.as_f64().ok_or_else(|| {
115        ApiError::invalid_input(format!(
116            "Invalid rate_hz for capability '{}': expected number",
117            capability_key
118        ))
119    })?;
120
121    if rate_hz <= 0.0 {
122        return Err(ApiError::invalid_input(format!(
123            "Invalid rate_hz for capability '{}': must be > 0",
124            capability_key
125        )));
126    }
127
128    Ok(Some(rate_hz))
129}
130
131#[cfg(feature = "feagi-agent")]
132#[allow(dead_code)]
133fn capability_key(capability: &RegistrationCapabilities) -> &'static str {
134    match capability {
135        RegistrationCapabilities::SendSensorData => "send_sensor_data",
136        RegistrationCapabilities::ReceiveMotorData => "receive_motor_data",
137        RegistrationCapabilities::ReceiveNeuronVisualizations => "receive_neuron_visualizations",
138        RegistrationCapabilities::ReceiveSystemMessages => "receive_system_messages",
139    }
140}
141
142/// Resolve a human-readable agent name from agent_id.
143/// Uses agent_handler descriptor when available; otherwise attempts to decode
144/// 48-byte AgentDescriptor format (instance_id(4) + manufacturer(20) + agent_name(20) + version(4)).
145fn get_agent_name_from_id(state: &ApiState, agent_id: &str) -> ApiResult<String> {
146    #[cfg(feature = "feagi-agent")]
147    {
148        if let Some(handler) = &state.agent_handler {
149            if let Ok(parsed) = parse_agent_id_base64(agent_id) {
150                let guard = handler.lock().unwrap();
151                if let Some((descriptor, _)) = guard.get_all_registered_agents().get(&parsed) {
152                    return Ok(descriptor.agent_name().to_string());
153                }
154            }
155        }
156    }
157    if let Ok(name) = try_extract_agent_name_from_descriptor_bytes(agent_id) {
158        return Ok(name);
159    }
160    Ok(agent_id.to_string())
161}
162
163/// Attempt to extract agent_name from base64 agent_id if it is 48-byte AgentDescriptor format.
164fn try_extract_agent_name_from_descriptor_bytes(agent_id: &str) -> Result<String, ()> {
165    use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
166    const AGENT_DESC_SIZE: usize = 48;
167    const AGENT_NAME_OFFSET: usize = 4 + 20;
168    const AGENT_NAME_LEN: usize = 20;
169    let decoded = BASE64.decode(agent_id).map_err(|_| ())?;
170    if decoded.len() != AGENT_DESC_SIZE {
171        return Err(());
172    }
173    let name_bytes = &decoded[AGENT_NAME_OFFSET..AGENT_NAME_OFFSET + AGENT_NAME_LEN];
174    let name = String::from_utf8_lossy(name_bytes)
175        .trim_end_matches('\0')
176        .trim()
177        .to_string();
178    if name.is_empty() || !name.chars().all(|c| c.is_ascii() && !c.is_control()) {
179        return Err(());
180    }
181    Ok(name)
182}
183
184#[cfg(feature = "feagi-agent")]
185fn registration_capabilities_to_map(
186    capabilities: &[RegistrationCapabilities],
187) -> HashMap<String, serde_json::Value> {
188    let mut out = HashMap::new();
189    for capability in capabilities {
190        match capability {
191            RegistrationCapabilities::SendSensorData => {
192                out.insert("send_sensor_data".to_string(), serde_json::json!(true));
193                out.insert("sensory".to_string(), serde_json::json!(true));
194            }
195            RegistrationCapabilities::ReceiveMotorData => {
196                out.insert("receive_motor_data".to_string(), serde_json::json!(true));
197                out.insert("motor".to_string(), serde_json::json!(true));
198            }
199            RegistrationCapabilities::ReceiveNeuronVisualizations => {
200                out.insert(
201                    "receive_neuron_visualizations".to_string(),
202                    serde_json::json!(true),
203                );
204                out.insert(
205                    "visualization".to_string(),
206                    serde_json::json!({"rate_hz": 0.0}),
207                );
208            }
209            RegistrationCapabilities::ReceiveSystemMessages => {
210                out.insert(
211                    "receive_system_messages".to_string(),
212                    serde_json::json!(true),
213                );
214            }
215        }
216    }
217    out
218}
219
220#[cfg(feature = "feagi-agent")]
221fn list_agent_ids_from_handler(state: &ApiState) -> Vec<String> {
222    if let Some(handler) = &state.agent_handler {
223        let handler_guard = handler.lock().unwrap();
224        return handler_guard
225            .get_all_registered_agents()
226            .keys()
227            .map(|id| id.to_base64())
228            .collect();
229    }
230    Vec::new()
231}
232
233#[cfg(feature = "feagi-agent")]
234fn get_handler_capabilities_for_agent(
235    state: &ApiState,
236    agent_id: &str,
237) -> Option<HashMap<String, serde_json::Value>> {
238    let parsed_agent_id = parse_agent_id_base64(agent_id).ok()?;
239    let handler = state.agent_handler.as_ref()?;
240    let handler_guard = handler.lock().unwrap();
241    let (_, capabilities) = handler_guard
242        .get_all_registered_agents()
243        .get(&parsed_agent_id)?;
244    Some(registration_capabilities_to_map(capabilities))
245}
246
247#[cfg(feature = "feagi-agent")]
248fn parse_agent_id_base64(agent_id: &str) -> ApiResult<AgentID> {
249    AgentID::try_from_base64(agent_id).map_err(|e| {
250        ApiError::invalid_input(format!("Invalid agent_id (expected AgentID base64): {}", e))
251    })
252}
253
254#[cfg(feature = "feagi-agent")]
255async fn auto_create_cortical_areas_from_device_registrations(
256    state: &ApiState,
257    device_registrations: &serde_json::Value,
258) {
259    auto_create_cortical_areas_shared(state, device_registrations).await;
260}
261
262#[cfg(not(feature = "feagi-agent"))]
263async fn auto_create_cortical_areas_from_device_registrations(
264    _state: &ApiState,
265    _device_registrations: &serde_json::Value,
266) {
267    // No-op when feature is disabled
268}
269
270/// Register a new agent with FEAGI and receive connection details including transport configuration and ports.
271#[utoipa::path(
272    post,
273    path = "/v1/agent/register",
274    request_body = AgentRegistrationRequest,
275    responses(
276        (status = 200, description = "Agent registered successfully", body = AgentRegistrationResponse),
277        (status = 500, description = "Registration failed", body = String)
278    ),
279    tag = "agent"
280)]
281pub async fn register_agent(
282    State(state): State<ApiState>,
283    Json(request): Json<AgentRegistrationRequest>,
284) -> ApiResult<Json<AgentRegistrationResponse>> {
285    info!(
286        "đŸĻ€ [API] Registration request received for agent '{}' (type: {})",
287        request.agent_id, request.agent_type
288    );
289
290    // Extract device_registrations from capabilities
291    let device_registrations_opt = request
292        .capabilities
293        .get("device_registrations")
294        .and_then(|v| v.as_object().map(|_| v.clone()));
295
296    // Clone device_regs before async operations to avoid borrow issues
297    let device_regs_for_autocreate = device_registrations_opt.clone();
298
299    // Store device registrations in handler if provided (no await here)
300    let handler_available = if let Some(device_regs) = &device_registrations_opt {
301        if let Some(handler) = &state.agent_handler {
302            let agent_id = parse_agent_id_base64(&request.agent_id)?;
303            {
304                let mut handler_guard = handler.lock().unwrap();
305                handler_guard.set_device_registrations_by_agent(agent_id, device_regs.clone());
306            } // Drop guard before any await
307            info!(
308                "✅ [API] Stored device registrations for agent '{}'",
309                request.agent_id
310            );
311            true
312        } else {
313            false
314        }
315    } else {
316        state.agent_handler.is_some()
317    };
318
319    // Now safe to await
320    if let Some(device_regs) = device_regs_for_autocreate {
321        auto_create_cortical_areas_from_device_registrations(&state, &device_regs).await;
322    }
323
324    // Register visualization subscription if visualization capability is present
325    if let Some(viz) = request.capabilities.get("visualization") {
326        if let Some(rate_hz) = viz.get("rate_hz").and_then(|v| v.as_f64()) {
327            if rate_hz > 0.0 {
328                match state
329                    .runtime_service
330                    .register_visualization_subscriptions(&request.agent_id, rate_hz)
331                    .await
332                {
333                    Ok(_) => {
334                        info!(
335                            "✅ [API] Registered visualization subscription for agent '{}' at {}Hz",
336                            request.agent_id, rate_hz
337                        );
338                    }
339                    Err(e) => {
340                        warn!(
341                            "âš ī¸  [API] Failed to register visualization subscription for agent '{}': {}",
342                            request.agent_id, e
343                        );
344                    }
345                }
346            }
347        }
348    }
349
350    // Get available endpoints from handler and build TransportConfig objects
351    use crate::v1::TransportConfig;
352    let transports_array = if state.agent_handler.is_some() {
353        // Load config to get port numbers
354        use feagi_config::load_config;
355        let config = load_config(None, None)
356            .map_err(|e| ApiError::internal(format!("Failed to load config: {}", e)))?;
357
358        let mut transport_configs = Vec::new();
359        for transport in &config.transports.available {
360            let transport_type = transport.to_lowercase();
361            if transport_type != "zmq" && transport_type != "websocket" && transport_type != "ws" {
362                continue;
363            }
364
365            // Build ports map from config
366            let mut ports = HashMap::new();
367            if transport_type == "websocket" || transport_type == "ws" {
368                ports.insert(
369                    "registration".to_string(),
370                    config.websocket.registration_port,
371                );
372                ports.insert("sensory".to_string(), config.websocket.sensory_port);
373                ports.insert("motor".to_string(), config.websocket.motor_port);
374                ports.insert(
375                    "visualization".to_string(),
376                    config.websocket.visualization_port,
377                );
378            } else {
379                ports.insert("registration".to_string(), config.agent.registration_port);
380                ports.insert("sensory".to_string(), config.ports.zmq_sensory_port);
381                ports.insert("motor".to_string(), config.ports.zmq_motor_port);
382                ports.insert(
383                    "visualization".to_string(),
384                    config.ports.zmq_visualization_port,
385                );
386            }
387
388            transport_configs.push(TransportConfig {
389                transport_type: if transport_type == "ws" {
390                    "websocket".to_string()
391                } else {
392                    transport_type
393                },
394                enabled: true,
395                ports,
396                host: if transport == "websocket" || transport == "ws" {
397                    config.websocket.advertised_host.clone()
398                } else {
399                    config.zmq.advertised_host.clone()
400                },
401            });
402        }
403
404        transport_configs
405    } else {
406        if !handler_available {
407            return Err(ApiError::internal("Agent handler not available"));
408        }
409        Vec::new()
410    };
411
412    Ok(Json(AgentRegistrationResponse {
413        status: "success".to_string(),
414        message: "Agent configuration stored. Connect via ZMQ/WebSocket for full registration"
415            .to_string(),
416        success: true,
417        transport: None, // Legacy field - deprecated
418        rates: None,
419        transports: Some(transports_array),
420        recommended_transport: Some("websocket".to_string()),
421        shm_paths: None,
422        cortical_areas: serde_json::json!({}),
423    }))
424}
425
426/// Send a heartbeat to keep the agent registered and prevent timeout disconnection.
427#[utoipa::path(
428    post,
429    path = "/v1/agent/heartbeat",
430    request_body = HeartbeatRequest,
431    responses(
432        (status = 200, description = "Heartbeat recorded", body = HeartbeatResponse),
433        (status = 404, description = "Agent not found"),
434        (status = 500, description = "Heartbeat failed")
435    ),
436    tag = "agent"
437)]
438pub async fn heartbeat(
439    State(state): State<ApiState>,
440    Json(request): Json<HeartbeatRequest>,
441) -> ApiResult<Json<HeartbeatResponse>> {
442    let agent_service = state
443        .agent_service
444        .as_ref()
445        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
446
447    let service_request = ServiceHeartbeatRequest {
448        agent_id: request.agent_id.clone(),
449    };
450
451    match agent_service.heartbeat(service_request).await {
452        Ok(_) => Ok(Json(HeartbeatResponse {
453            message: "heartbeat_ok".to_string(),
454            success: true,
455        })),
456        Err(_) => Err(ApiError::not_found(
457            "agent",
458            &format!("Agent {} not in registry", request.agent_id),
459        )),
460    }
461}
462
463/// Get a list of all currently registered agent IDs.
464#[utoipa::path(
465    get,
466    path = "/v1/agent/list",
467    responses(
468        (status = 200, description = "List of agent IDs", body = Vec<String>),
469        (status = 503, description = "Registration service unavailable")
470    ),
471    tag = "agent"
472)]
473pub async fn list_agents(State(state): State<ApiState>) -> ApiResult<Json<Vec<String>>> {
474    if let Some(agent_service) = state.agent_service.as_ref() {
475        match agent_service.list_agents().await {
476            Ok(agent_ids) if !agent_ids.is_empty() => return Ok(Json(agent_ids)),
477            Ok(_) => {}
478            Err(e) => {
479                warn!(
480                    "âš ī¸ [API] AgentService list_agents failed ({}); falling back to agent_handler",
481                    e
482                );
483            }
484        }
485    }
486
487    #[cfg(feature = "feagi-agent")]
488    {
489        Ok(Json(list_agent_ids_from_handler(&state)))
490    }
491
492    #[cfg(not(feature = "feagi-agent"))]
493    {
494        Err(ApiError::internal("Agent service not available"))
495    }
496}
497
498/// Get agent properties including type, capabilities, version, and connection details. Uses query parameter ?agent_id=xxx.
499#[utoipa::path(
500    get,
501    path = "/v1/agent/properties",
502    params(
503        ("agent_id" = String, Query, description = "Agent ID to get properties for")
504    ),
505    responses(
506        (status = 200, description = "Agent properties", body = AgentPropertiesResponse),
507        (status = 404, description = "Agent not found"),
508        (status = 500, description = "Failed to get agent properties")
509    ),
510    tag = "agent"
511)]
512pub async fn get_agent_properties(
513    State(state): State<ApiState>,
514    Query(params): Query<HashMap<String, String>>,
515) -> ApiResult<Json<AgentPropertiesResponse>> {
516    let agent_id = params
517        .get("agent_id")
518        .ok_or_else(|| ApiError::invalid_input("Missing agent_id query parameter"))?;
519
520    let agent_service = state
521        .agent_service
522        .as_ref()
523        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
524
525    let agent_name = get_agent_name_from_id(&state, agent_id)?;
526    match agent_service.get_agent_properties(agent_id).await {
527        Ok(properties) => Ok(Json(AgentPropertiesResponse {
528            agent_name,
529            agent_type: properties.agent_type,
530            agent_ip: properties.agent_ip,
531            agent_data_port: properties.agent_data_port,
532            agent_router_address: properties.agent_router_address,
533            agent_version: properties.agent_version,
534            controller_version: properties.controller_version,
535            capabilities: properties.capabilities,
536            chosen_transport: properties.chosen_transport,
537        })),
538        Err(e) => Err(ApiError::not_found("agent", &format!("{}", e))),
539    }
540}
541
542/// Get shared memory configuration and paths for all registered agents using shared memory transport.
543#[utoipa::path(
544    get,
545    path = "/v1/agent/shared_mem",
546    responses(
547        (status = 200, description = "Shared memory info", body = HashMap<String, HashMap<String, serde_json::Value>>),
548        (status = 500, description = "Failed to get shared memory info")
549    ),
550    tag = "agent"
551)]
552pub async fn get_shared_memory(
553    State(state): State<ApiState>,
554) -> ApiResult<Json<HashMap<String, HashMap<String, serde_json::Value>>>> {
555    let agent_service = state
556        .agent_service
557        .as_ref()
558        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
559
560    match agent_service.get_shared_memory_info().await {
561        Ok(shm_info) => Ok(Json(shm_info)),
562        Err(e) => Err(ApiError::internal(format!(
563            "Failed to get shared memory info: {}",
564            e
565        ))),
566    }
567}
568
569/// Deregister an agent from FEAGI and clean up its resources.
570#[utoipa::path(
571    delete,
572    path = "/v1/agent/deregister",
573    request_body = AgentDeregistrationRequest,
574    responses(
575        (status = 200, description = "Agent deregistered successfully", body = SuccessResponse),
576        (status = 500, description = "Deregistration failed")
577    ),
578    tag = "agent"
579)]
580pub async fn deregister_agent(
581    State(state): State<ApiState>,
582    Json(request): Json<AgentDeregistrationRequest>,
583) -> ApiResult<Json<SuccessResponse>> {
584    let agent_service = state
585        .agent_service
586        .as_ref()
587        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
588
589    match agent_service.deregister_agent(&request.agent_id).await {
590        Ok(_) => Ok(Json(SuccessResponse {
591            message: format!("Agent '{}' deregistered successfully", request.agent_id),
592            success: Some(true),
593        })),
594        Err(e) => Err(ApiError::internal(format!("Deregistration failed: {}", e))),
595    }
596}
597
598/// Manually stimulate neurons at specific coordinates across multiple cortical areas for testing and debugging.
599#[utoipa::path(
600    post,
601    path = "/v1/agent/manual_stimulation",
602    request_body = ManualStimulationRequest,
603    responses(
604        (status = 200, description = "Manual stimulation result", body = ManualStimulationResponse),
605        (status = 500, description = "Stimulation failed")
606    ),
607    tag = "agent"
608)]
609pub async fn manual_stimulation(
610    State(state): State<ApiState>,
611    Json(request): Json<ManualStimulationRequest>,
612) -> ApiResult<Json<ManualStimulationResponse>> {
613    let agent_service = state
614        .agent_service
615        .as_ref()
616        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
617
618    // Ensure runtime service is connected to agent service (if not already connected)
619    // This allows runtime_service to be set after AgentServiceImpl is wrapped in Arc
620    agent_service.try_set_runtime_service(state.runtime_service.clone());
621
622    let mode = match request.mode.as_deref() {
623        Some("force_fire") => ManualStimulationMode::ForceFire,
624        Some("candidate") | None => ManualStimulationMode::Candidate,
625        Some(other) => {
626            return Err(ApiError::invalid_input(format!(
627                "Invalid stimulation mode '{}'; expected 'candidate' or 'force_fire'",
628                other
629            )));
630        }
631    };
632
633    match agent_service
634        .manual_stimulation(request.stimulation_payload, mode)
635        .await
636    {
637        Ok(result) => {
638            let success = result
639                .get("success")
640                .and_then(|v| v.as_bool())
641                .unwrap_or(false);
642            let total_coordinates = result
643                .get("total_coordinates")
644                .and_then(|v| v.as_u64())
645                .unwrap_or(0) as usize;
646            let successful_areas = result
647                .get("successful_areas")
648                .and_then(|v| v.as_u64())
649                .unwrap_or(0) as usize;
650            let requested_coordinates = result
651                .get("requested_coordinates")
652                .and_then(|v| v.as_u64())
653                .unwrap_or(0) as usize;
654            let matched_coordinates = result
655                .get("matched_coordinates")
656                .and_then(|v| v.as_u64())
657                .unwrap_or(0) as usize;
658            let unique_neuron_ids = result
659                .get("unique_neuron_ids")
660                .and_then(|v| v.as_u64())
661                .unwrap_or(0) as usize;
662            let mode = result
663                .get("mode")
664                .and_then(|v| v.as_str())
665                .unwrap_or("candidate")
666                .to_string();
667            let failed_areas = result
668                .get("failed_areas")
669                .and_then(|v| v.as_array())
670                .map(|arr| {
671                    arr.iter()
672                        .filter_map(|v| v.as_str().map(String::from))
673                        .collect()
674                })
675                .unwrap_or_default();
676            let error = result
677                .get("error")
678                .and_then(|v| v.as_str())
679                .map(String::from);
680
681            Ok(Json(ManualStimulationResponse {
682                success,
683                total_coordinates,
684                requested_coordinates,
685                matched_coordinates,
686                unique_neuron_ids,
687                mode,
688                successful_areas,
689                failed_areas,
690                error,
691            }))
692        }
693        Err(e) => Err(ApiError::internal(format!("Stimulation failed: {}", e))),
694    }
695}
696
697/// Get Fire Queue (FQ) sampler coordination status including visualization and motor sampling configuration.
698#[utoipa::path(
699    get,
700    path = "/v1/agent/fq_sampler_status",
701    responses(
702        (status = 200, description = "FQ sampler status", body = HashMap<String, serde_json::Value>),
703        (status = 500, description = "Failed to get FQ sampler status")
704    ),
705    tag = "agent"
706)]
707pub async fn get_fq_sampler_status(
708    State(state): State<ApiState>,
709) -> ApiResult<Json<HashMap<String, serde_json::Value>>> {
710    let agent_service = state
711        .agent_service
712        .as_ref()
713        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
714
715    let runtime_service = state.runtime_service.as_ref();
716
717    // Get all agents
718    let agent_ids = agent_service
719        .list_agents()
720        .await
721        .map_err(|e| ApiError::internal(format!("Failed to list agents: {}", e)))?;
722
723    // Get FCL sampler config from RuntimeService
724    let (fcl_frequency, fcl_consumer) = runtime_service
725        .get_fcl_sampler_config()
726        .await
727        .map_err(|e| ApiError::internal(format!("Failed to get sampler config: {}", e)))?;
728
729    // Build response matching Python structure
730    let mut visualization_agents = Vec::new();
731    let mut motor_agents = Vec::new();
732
733    for agent_id in &agent_ids {
734        if let Ok(props) = agent_service.get_agent_properties(agent_id).await {
735            if props.capabilities.contains_key("visualization") {
736                visualization_agents.push(agent_id.clone());
737            }
738            if props.capabilities.contains_key("motor") {
739                motor_agents.push(agent_id.clone());
740            }
741        }
742    }
743
744    let mut fq_coordination = HashMap::new();
745
746    let mut viz_sampler = HashMap::new();
747    viz_sampler.insert(
748        "enabled".to_string(),
749        serde_json::json!(!visualization_agents.is_empty()),
750    );
751    viz_sampler.insert(
752        "reason".to_string(),
753        serde_json::json!(if visualization_agents.is_empty() {
754            "No visualization agents connected".to_string()
755        } else {
756            format!(
757                "{} visualization agent(s) connected",
758                visualization_agents.len()
759            )
760        }),
761    );
762    viz_sampler.insert(
763        "agents_requiring".to_string(),
764        serde_json::json!(visualization_agents),
765    );
766    viz_sampler.insert("frequency_hz".to_string(), serde_json::json!(fcl_frequency));
767    fq_coordination.insert(
768        "visualization_fq_sampler".to_string(),
769        serde_json::json!(viz_sampler),
770    );
771
772    let mut motor_sampler = HashMap::new();
773    motor_sampler.insert(
774        "enabled".to_string(),
775        serde_json::json!(!motor_agents.is_empty()),
776    );
777    motor_sampler.insert(
778        "reason".to_string(),
779        serde_json::json!(if motor_agents.is_empty() {
780            "No motor agents connected".to_string()
781        } else {
782            format!("{} motor agent(s) connected", motor_agents.len())
783        }),
784    );
785    motor_sampler.insert(
786        "agents_requiring".to_string(),
787        serde_json::json!(motor_agents),
788    );
789    motor_sampler.insert("frequency_hz".to_string(), serde_json::json!(100.0));
790    fq_coordination.insert(
791        "motor_fq_sampler".to_string(),
792        serde_json::json!(motor_sampler),
793    );
794
795    let mut response = HashMap::new();
796    response.insert(
797        "fq_sampler_coordination".to_string(),
798        serde_json::json!(fq_coordination),
799    );
800    response.insert(
801        "agent_registry".to_string(),
802        serde_json::json!({
803            "total_agents": agent_ids.len(),
804            "agent_ids": agent_ids
805        }),
806    );
807    response.insert(
808        "system_status".to_string(),
809        serde_json::json!("coordinated_via_registration_manager"),
810    );
811    response.insert(
812        "fcl_sampler_consumer".to_string(),
813        serde_json::json!(fcl_consumer),
814    );
815
816    Ok(Json(response))
817}
818
819/// Get list of all supported agent types and capability types (sensory, motor, visualization, etc.).
820#[utoipa::path(
821    get,
822    path = "/v1/agent/capabilities",
823    responses(
824        (status = 200, description = "List of capabilities", body = HashMap<String, Vec<String>>),
825        (status = 500, description = "Failed to get capabilities")
826    ),
827    tag = "agent"
828)]
829pub async fn get_capabilities(
830    State(_state): State<ApiState>,
831) -> ApiResult<Json<HashMap<String, Vec<String>>>> {
832    let mut response = HashMap::new();
833    response.insert(
834        "agent_types".to_string(),
835        vec![
836            "sensory".to_string(),
837            "motor".to_string(),
838            "both".to_string(),
839            "visualization".to_string(),
840            "infrastructure".to_string(),
841        ],
842    );
843    response.insert(
844        "capability_types".to_string(),
845        vec![
846            "vision".to_string(),
847            "motor".to_string(),
848            "visualization".to_string(),
849            "sensory".to_string(),
850        ],
851    );
852
853    Ok(Json(response))
854}
855
856/// Get capabilities for all agents with optional filtering and payload includes.
857#[utoipa::path(
858    get,
859    path = "/v1/agent/capabilities/all",
860    params(
861        ("agent_type" = Option<String>, Query, description = "Filter by agent type (exact match)"),
862        ("capability" = Option<String>, Query, description = "Filter by capability key(s), comma-separated"),
863        ("include_device_registrations" = Option<bool>, Query, description = "Include device registration payloads per agent")
864    ),
865    responses(
866        (status = 200, description = "Agent capabilities map", body = HashMap<String, AgentCapabilitiesSummary>),
867        (status = 400, description = "Invalid query"),
868        (status = 500, description = "Failed to get agent capabilities")
869    ),
870    tag = "agent"
871)]
872pub async fn get_all_agent_capabilities(
873    State(state): State<ApiState>,
874    Query(params): Query<AgentCapabilitiesAllQuery>,
875) -> ApiResult<Json<HashMap<String, AgentCapabilitiesSummary>>> {
876    let agent_service = state.agent_service.as_ref();
877
878    let include_device_registrations = params.include_device_registrations.unwrap_or(false);
879    let capability_filters: Option<Vec<String>> = params.capability.as_ref().and_then(|value| {
880        let filters: Vec<String> = value
881            .split(',')
882            .map(|item| item.trim())
883            .filter(|item| !item.is_empty())
884            .map(String::from)
885            .collect();
886        if filters.is_empty() {
887            None
888        } else {
889            Some(filters)
890        }
891    });
892
893    let mut agent_ids: Vec<String> = Vec::new();
894    if let Some(service) = agent_service {
895        match service.list_agents().await {
896            Ok(ids) => agent_ids = ids,
897            Err(e) => {
898                warn!(
899                    "âš ī¸ [API] AgentService list_agents failed ({}); falling back to agent_handler",
900                    e
901                );
902            }
903        }
904    }
905    #[cfg(feature = "feagi-agent")]
906    if agent_ids.is_empty() {
907        agent_ids = list_agent_ids_from_handler(&state);
908    }
909
910    let mut response: HashMap<String, AgentCapabilitiesSummary> = HashMap::new();
911
912    for agent_id in agent_ids {
913        let agent_name = get_agent_name_from_id(&state, &agent_id)?;
914        let (agent_type, capabilities_map) = if let Some(service) = agent_service {
915            match service.get_agent_properties(&agent_id).await {
916                Ok(props) => (props.agent_type, props.capabilities),
917                Err(_) => {
918                    #[cfg(feature = "feagi-agent")]
919                    {
920                        if let Some(caps) = get_handler_capabilities_for_agent(&state, &agent_id) {
921                            ("unknown".to_string(), caps)
922                        } else {
923                            continue;
924                        }
925                    }
926                    #[cfg(not(feature = "feagi-agent"))]
927                    {
928                        continue;
929                    }
930                }
931            }
932        } else {
933            #[cfg(feature = "feagi-agent")]
934            {
935                if let Some(caps) = get_handler_capabilities_for_agent(&state, &agent_id) {
936                    ("unknown".to_string(), caps)
937                } else {
938                    continue;
939                }
940            }
941            #[cfg(not(feature = "feagi-agent"))]
942            {
943                continue;
944            }
945        };
946
947        if let Some(ref agent_type_filter) = params.agent_type {
948            if agent_type != *agent_type_filter {
949                continue;
950            }
951        }
952
953        if let Some(ref filters) = capability_filters {
954            let has_match = filters
955                .iter()
956                .any(|capability| capabilities_map.contains_key(capability));
957            if !has_match {
958                continue;
959            }
960        }
961
962        let device_registrations = if include_device_registrations {
963            #[cfg(feature = "feagi-agent")]
964            {
965                match export_device_registrations_from_connector(&state, &agent_id) {
966                    Ok(v) => Some(v),
967                    Err(e) => {
968                        warn!(
969                            "âš ī¸ [API] Could not export device registrations for '{}': {:?}",
970                            agent_id, e
971                        );
972                        None
973                    }
974                }
975            }
976            #[cfg(not(feature = "feagi-agent"))]
977            {
978                None
979            }
980        } else {
981            None
982        };
983        response.insert(
984            agent_id,
985            AgentCapabilitiesSummary {
986                agent_name,
987                capabilities: capabilities_map,
988                device_registrations,
989            },
990        );
991    }
992
993    Ok(Json(response))
994}
995
996#[cfg(feature = "feagi-agent")]
997fn export_device_registrations_from_connector(
998    state: &ApiState,
999    agent_id: &str,
1000) -> ApiResult<serde_json::Value> {
1001    let parsed_agent_id = parse_agent_id_base64(agent_id)?;
1002    if let Some(handler) = &state.agent_handler {
1003        let handler_guard = handler.lock().unwrap();
1004        if let Some(regs) = handler_guard.get_device_registrations_by_agent(parsed_agent_id) {
1005            return Ok(regs.clone());
1006        }
1007    }
1008
1009    Err(ApiError::not_found(
1010        "device_registrations",
1011        &format!("No device registrations found for agent '{}'", agent_id),
1012    ))
1013}
1014
1015/// Get comprehensive agent information including status, capabilities, version, and connection details.
1016#[utoipa::path(
1017    get,
1018    path = "/v1/agent/info/{agent_id}",
1019    params(
1020        ("agent_id" = String, Path, description = "Agent ID")
1021    ),
1022    responses(
1023        (status = 200, description = "Agent detailed info", body = HashMap<String, serde_json::Value>),
1024        (status = 404, description = "Agent not found"),
1025        (status = 500, description = "Failed to get agent info")
1026    ),
1027    tag = "agent"
1028)]
1029pub async fn get_agent_info(
1030    State(state): State<ApiState>,
1031    Path(agent_id): Path<String>,
1032) -> ApiResult<Json<HashMap<String, serde_json::Value>>> {
1033    let agent_service = state
1034        .agent_service
1035        .as_ref()
1036        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
1037
1038    let properties = agent_service
1039        .get_agent_properties(&agent_id)
1040        .await
1041        .map_err(|e| ApiError::not_found("agent", &e.to_string()))?;
1042
1043    let mut response = HashMap::new();
1044    response.insert("agent_id".to_string(), serde_json::json!(agent_id));
1045    response.insert(
1046        "agent_name".to_string(),
1047        serde_json::json!(get_agent_name_from_id(&state, &agent_id)?),
1048    );
1049    response.insert(
1050        "agent_type".to_string(),
1051        serde_json::json!(properties.agent_type),
1052    );
1053    response.insert(
1054        "agent_ip".to_string(),
1055        serde_json::json!(properties.agent_ip),
1056    );
1057    response.insert(
1058        "agent_data_port".to_string(),
1059        serde_json::json!(properties.agent_data_port),
1060    );
1061    response.insert(
1062        "capabilities".to_string(),
1063        serde_json::json!(properties.capabilities),
1064    );
1065    response.insert(
1066        "agent_version".to_string(),
1067        serde_json::json!(properties.agent_version),
1068    );
1069    response.insert(
1070        "controller_version".to_string(),
1071        serde_json::json!(properties.controller_version),
1072    );
1073    response.insert("status".to_string(), serde_json::json!("active"));
1074    if let Some(ref transport) = properties.chosen_transport {
1075        response.insert("chosen_transport".to_string(), serde_json::json!(transport));
1076    }
1077
1078    Ok(Json(response))
1079}
1080
1081/// Get agent properties using path parameter. Same as /v1/agent/properties but with agent_id in the URL path.
1082#[utoipa::path(
1083    get,
1084    path = "/v1/agent/properties/{agent_id}",
1085    params(
1086        ("agent_id" = String, Path, description = "Agent ID")
1087    ),
1088    responses(
1089        (status = 200, description = "Agent properties", body = AgentPropertiesResponse),
1090        (status = 404, description = "Agent not found"),
1091        (status = 500, description = "Failed to get agent properties")
1092    ),
1093    tag = "agent"
1094)]
1095pub async fn get_agent_properties_path(
1096    State(state): State<ApiState>,
1097    Path(agent_id): Path<String>,
1098) -> ApiResult<Json<AgentPropertiesResponse>> {
1099    let agent_service = state
1100        .agent_service
1101        .as_ref()
1102        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
1103
1104    match agent_service.get_agent_properties(&agent_id).await {
1105        Ok(properties) => Ok(Json(AgentPropertiesResponse {
1106            agent_name: get_agent_name_from_id(&state, &agent_id)?,
1107            agent_type: properties.agent_type,
1108            agent_ip: properties.agent_ip,
1109            agent_data_port: properties.agent_data_port,
1110            agent_router_address: properties.agent_router_address,
1111            agent_version: properties.agent_version,
1112            controller_version: properties.controller_version,
1113            capabilities: properties.capabilities,
1114            chosen_transport: properties.chosen_transport,
1115        })),
1116        Err(e) => Err(ApiError::not_found("agent", &format!("{}", e))),
1117    }
1118}
1119
1120/// Configure agent parameters and settings. (Not yet implemented)
1121#[utoipa::path(
1122    post,
1123    path = "/v1/agent/configure",
1124    responses(
1125        (status = 200, description = "Agent configured", body = HashMap<String, String>),
1126        (status = 400, description = "Invalid input"),
1127        (status = 500, description = "Failed to configure agent")
1128    ),
1129    tag = "agent"
1130)]
1131pub async fn post_configure(
1132    State(_state): State<ApiState>,
1133    Json(config): Json<HashMap<String, serde_json::Value>>,
1134) -> ApiResult<Json<HashMap<String, String>>> {
1135    tracing::info!(target: "feagi-api", "Agent configuration requested: {} params", config.len());
1136
1137    Ok(Json(HashMap::from([
1138        (
1139            "message".to_string(),
1140            "Agent configuration updated".to_string(),
1141        ),
1142        ("status".to_string(), "not_yet_implemented".to_string()),
1143    ])))
1144}
1145
1146/// Export device registrations for an agent
1147///
1148/// Returns the complete device registration configuration including
1149/// sensor and motor device registrations, encoder/decoder properties,
1150/// and feedback configurations in the format compatible with
1151/// ConnectorAgent::get_device_registration_json.
1152#[utoipa::path(
1153    get,
1154    path = "/v1/agent/{agent_id}/device_registrations",
1155    params(
1156        ("agent_id" = String, Path, description = "Agent ID")
1157    ),
1158    responses(
1159        (status = 200, description = "Device registrations exported successfully", body = DeviceRegistrationExportResponse),
1160        (status = 404, description = "Agent not found"),
1161        (status = 500, description = "Failed to export device registrations")
1162    ),
1163    tag = "agent"
1164)]
1165pub async fn export_device_registrations(
1166    State(state): State<ApiState>,
1167    Path(agent_id): Path<String>,
1168) -> ApiResult<Json<DeviceRegistrationExportResponse>> {
1169    info!(
1170        "đŸĻ€ [API] Device registration export requested for agent '{}'",
1171        agent_id
1172    );
1173
1174    // Best-effort existence check via AgentService.
1175    // Do not fail hard here: ZMQ-only registrations can exist in agent_handler
1176    // before/without AgentService registry visibility.
1177    if let Some(agent_service) = state.agent_service.as_ref() {
1178        if let Err(e) = agent_service.get_agent_properties(&agent_id).await {
1179            info!(
1180                "â„šī¸ [API] Agent '{}' not found in AgentService during export ({}); falling back to agent_handler lookup",
1181                agent_id, e
1182            );
1183        }
1184    }
1185
1186    // Get device registrations from agent_handler
1187    #[cfg(feature = "feagi-agent")]
1188    let device_registrations = {
1189        let parsed_agent_id = parse_agent_id_base64(&agent_id)?;
1190        if let Some(handler) = &state.agent_handler {
1191            let handler_guard = handler.lock().unwrap();
1192            if let Some(regs) = handler_guard.get_device_registrations_by_agent(parsed_agent_id) {
1193                info!(
1194                    "📤 [API] Found device registrations for agent '{}'",
1195                    agent_id
1196                );
1197                regs.clone()
1198            } else {
1199                warn!(
1200                    "âš ī¸ [API] No device registrations found for agent '{}'",
1201                    agent_id
1202                );
1203                serde_json::json!({
1204                    "input_units_and_encoder_properties": {},
1205                    "output_units_and_decoder_properties": {},
1206                    "feedbacks": []
1207                })
1208            }
1209        } else {
1210            warn!("âš ī¸ [API] No agent_handler available");
1211            serde_json::json!({
1212                "input_units_and_encoder_properties": {},
1213                "output_units_and_decoder_properties": {},
1214                "feedbacks": []
1215            })
1216        }
1217    };
1218
1219    #[cfg(not(feature = "feagi-agent"))]
1220    // @architecture:acceptable - fallback when feature is disabled
1221    // Returns empty structure when feagi-agent feature is not compiled in
1222    let device_registrations = serde_json::json!({
1223        "input_units_and_encoder_properties": {},
1224        "output_units_and_decoder_properties": {},
1225        "feedbacks": []
1226    });
1227
1228    info!(
1229        "✅ [API] Device registration export succeeded for agent '{}'",
1230        agent_id
1231    );
1232
1233    Ok(Json(DeviceRegistrationExportResponse {
1234        device_registrations,
1235        agent_id,
1236    }))
1237}
1238
1239/// Import device registrations for an agent
1240///
1241/// Imports a device registration configuration, replacing all existing
1242/// device registrations for the agent. The configuration must be in
1243/// the format compatible with ConnectorAgent::set_device_registrations_from_json.
1244///
1245/// # Warning
1246/// This operation **wipes all existing registered devices** before importing
1247/// the new configuration.
1248#[utoipa::path(
1249    post,
1250    path = "/v1/agent/{agent_id}/device_registrations",
1251    params(
1252        ("agent_id" = String, Path, description = "Agent ID")
1253    ),
1254    request_body = DeviceRegistrationImportRequest,
1255    responses(
1256        (status = 200, description = "Device registrations imported successfully", body = DeviceRegistrationImportResponse),
1257        (status = 400, description = "Invalid device registration configuration"),
1258        (status = 404, description = "Agent not found"),
1259        (status = 500, description = "Failed to import device registrations")
1260    ),
1261    tag = "agent"
1262)]
1263pub async fn import_device_registrations(
1264    State(state): State<ApiState>,
1265    Path(agent_id): Path<String>,
1266    Json(request): Json<DeviceRegistrationImportRequest>,
1267) -> ApiResult<Json<DeviceRegistrationImportResponse>> {
1268    info!(
1269        "đŸĻ€ [API] Device registration import requested for agent '{}'",
1270        agent_id
1271    );
1272
1273    // Best-effort existence check via AgentService.
1274    // Do not fail hard here: ZMQ-only registrations can be handled via agent_handler.
1275    if let Some(agent_service) = state.agent_service.as_ref() {
1276        if let Err(e) = agent_service.get_agent_properties(&agent_id).await {
1277            info!(
1278                "â„šī¸ [API] Agent '{}' not found in AgentService during import ({}); continuing with agent_handler",
1279                agent_id, e
1280            );
1281        }
1282    }
1283
1284    // Validate the device registration JSON structure
1285    // Check that it has the expected fields
1286    if !request.device_registrations.is_object() {
1287        return Err(ApiError::invalid_input(
1288            "Device registrations must be a JSON object",
1289        ));
1290    }
1291
1292    // Validate required fields exist
1293    let obj = request.device_registrations.as_object().unwrap();
1294    if !obj.contains_key("input_units_and_encoder_properties")
1295        || !obj.contains_key("output_units_and_decoder_properties")
1296        || !obj.contains_key("feedbacks")
1297    {
1298        return Err(ApiError::invalid_input(
1299            "Device registrations must contain: input_units_and_encoder_properties, output_units_and_decoder_properties, and feedbacks",
1300        ));
1301    }
1302
1303    // Store device registrations in agent_handler
1304    #[cfg(feature = "feagi-agent")]
1305    {
1306        let parsed_agent_id = parse_agent_id_base64(&agent_id)?;
1307        if let Some(handler) = &state.agent_handler {
1308            let mut handler_guard = handler.lock().unwrap();
1309            let device_regs = request.device_registrations.clone();
1310            handler_guard.set_device_registrations_by_agent(parsed_agent_id, device_regs.clone());
1311            // Also set by descriptor so feagi-rs motor subscription lookup finds it
1312            let descriptor_opt = handler_guard
1313                .get_all_registered_agents()
1314                .get(&parsed_agent_id)
1315                .map(|(d, _)| d.clone());
1316            if let Some(descriptor) = descriptor_opt {
1317                handler_guard.set_device_registrations_by_descriptor(
1318                    agent_id.clone(),
1319                    descriptor,
1320                    device_regs,
1321                );
1322            }
1323            info!(
1324                "đŸ“Ĩ [API] Imported device registrations for agent '{}'",
1325                agent_id
1326            );
1327        } else {
1328            warn!("âš ī¸ [API] No agent_handler available to store device registrations");
1329        }
1330
1331        auto_create_cortical_areas_from_device_registrations(&state, &request.device_registrations)
1332            .await;
1333
1334        Ok(Json(DeviceRegistrationImportResponse {
1335            success: true,
1336            message: format!(
1337                "Device registrations imported successfully for agent '{}'",
1338                agent_id
1339            ),
1340            agent_id,
1341        }))
1342    }
1343
1344    #[cfg(not(feature = "feagi-agent"))]
1345    {
1346        info!(
1347            "✅ [API] Device registration import succeeded for agent '{}' (feagi-agent feature not enabled)",
1348            agent_id
1349        );
1350        Ok(Json(DeviceRegistrationImportResponse {
1351            success: true,
1352            message: format!(
1353                "Device registrations imported successfully for agent '{}' (feature not enabled)",
1354                agent_id
1355            ),
1356            agent_id,
1357        }))
1358    }
1359}