feagi_api/endpoints/
agent.rs

1// Copyright 2025 Neuraville Inc.
2// Licensed under the Apache License, Version 2.0
3
4//! Agent API 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::{
15    AgentRegistration, HeartbeatRequest as ServiceHeartbeatRequest,
16};
17
18/// Register a new agent with FEAGI and receive connection details including transport configuration and ports.
19#[utoipa::path(
20    post,
21    path = "/v1/agent/register",
22    request_body = AgentRegistrationRequest,
23    responses(
24        (status = 200, description = "Agent registered successfully", body = AgentRegistrationResponse),
25        (status = 500, description = "Registration failed", body = String)
26    ),
27    tag = "agent"
28)]
29pub async fn register_agent(
30    State(state): State<ApiState>,
31    Json(request): Json<AgentRegistrationRequest>,
32) -> ApiResult<Json<AgentRegistrationResponse>> {
33    let agent_service = state
34        .agent_service
35        .as_ref()
36        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
37
38    let registration = AgentRegistration {
39        agent_id: request.agent_id.clone(),
40        agent_type: request.agent_type,
41        agent_data_port: request.agent_data_port,
42        agent_version: request.agent_version,
43        controller_version: request.controller_version,
44        agent_ip: request.agent_ip,
45        capabilities: request.capabilities,
46        metadata: request.metadata,
47        chosen_transport: request.chosen_transport,
48    };
49
50    match agent_service.register_agent(registration).await {
51        Ok(response) => {
52            // Convert service TransportConfig to API TransportConfig
53            let transports = response.transports.map(|ts| {
54                ts.into_iter()
55                    .map(|t| crate::v1::agent_dtos::TransportConfig {
56                        transport_type: t.transport_type,
57                        enabled: t.enabled,
58                        ports: t.ports,
59                        host: t.host,
60                    })
61                    .collect()
62            });
63
64            Ok(Json(AgentRegistrationResponse {
65                status: response.status,
66                message: response.message,
67                success: response.success,
68                transport: response.transport,
69                rates: response.rates,
70                transports,
71                recommended_transport: response.recommended_transport,
72                zmq_ports: response.zmq_ports,
73                shm_paths: response.shm_paths,
74                cortical_areas: response.cortical_areas,
75            }))
76        }
77        Err(e) => {
78            // Check if error is about unsupported transport (validation error)
79            let error_msg = e.to_string();
80            if error_msg.contains("not supported") || error_msg.contains("disabled") {
81                Err(ApiError::invalid_input(error_msg))
82            } else {
83                Err(ApiError::internal(format!("Registration failed: {}", e)))
84            }
85        }
86    }
87}
88
89/// Send a heartbeat to keep the agent registered and prevent timeout disconnection.
90#[utoipa::path(
91    post,
92    path = "/v1/agent/heartbeat",
93    request_body = HeartbeatRequest,
94    responses(
95        (status = 200, description = "Heartbeat recorded", body = HeartbeatResponse),
96        (status = 404, description = "Agent not found"),
97        (status = 500, description = "Heartbeat failed")
98    ),
99    tag = "agent"
100)]
101pub async fn heartbeat(
102    State(state): State<ApiState>,
103    Json(request): Json<HeartbeatRequest>,
104) -> ApiResult<Json<HeartbeatResponse>> {
105    let agent_service = state
106        .agent_service
107        .as_ref()
108        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
109
110    let service_request = ServiceHeartbeatRequest {
111        agent_id: request.agent_id.clone(),
112    };
113
114    match agent_service.heartbeat(service_request).await {
115        Ok(_) => Ok(Json(HeartbeatResponse {
116            message: "heartbeat_ok".to_string(),
117            success: true,
118        })),
119        Err(e) => Err(ApiError::not_found("agent", &format!("{}", e))),
120    }
121}
122
123/// Get a list of all currently registered agent IDs.
124#[utoipa::path(
125    get,
126    path = "/v1/agent/list",
127    responses(
128        (status = 200, description = "List of agent IDs", body = Vec<String>),
129        (status = 503, description = "Registration service unavailable")
130    ),
131    tag = "agent"
132)]
133pub async fn list_agents(State(state): State<ApiState>) -> ApiResult<Json<Vec<String>>> {
134    let agent_service = state
135        .agent_service
136        .as_ref()
137        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
138
139    match agent_service.list_agents().await {
140        Ok(agent_ids) => Ok(Json(agent_ids)),
141        Err(e) => Err(ApiError::internal(format!("Failed to list agents: {}", e))),
142    }
143}
144
145/// Get agent properties including type, capabilities, version, and connection details. Uses query parameter ?agent_id=xxx.
146#[utoipa::path(
147    get,
148    path = "/v1/agent/properties",
149    params(
150        ("agent_id" = String, Query, description = "Agent ID to get properties for")
151    ),
152    responses(
153        (status = 200, description = "Agent properties", body = AgentPropertiesResponse),
154        (status = 404, description = "Agent not found"),
155        (status = 500, description = "Failed to get agent properties")
156    ),
157    tag = "agent"
158)]
159pub async fn get_agent_properties(
160    State(state): State<ApiState>,
161    Query(params): Query<HashMap<String, String>>,
162) -> ApiResult<Json<AgentPropertiesResponse>> {
163    let agent_id = params
164        .get("agent_id")
165        .ok_or_else(|| ApiError::invalid_input("Missing agent_id query parameter"))?;
166
167    let agent_service = state
168        .agent_service
169        .as_ref()
170        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
171
172    match agent_service.get_agent_properties(agent_id).await {
173        Ok(properties) => Ok(Json(AgentPropertiesResponse {
174            agent_type: properties.agent_type,
175            agent_ip: properties.agent_ip,
176            agent_data_port: properties.agent_data_port,
177            agent_router_address: properties.agent_router_address,
178            agent_version: properties.agent_version,
179            controller_version: properties.controller_version,
180            capabilities: properties.capabilities,
181            chosen_transport: properties.chosen_transport,
182        })),
183        Err(e) => Err(ApiError::not_found("agent", &format!("{}", e))),
184    }
185}
186
187/// Get shared memory configuration and paths for all registered agents using shared memory transport.
188#[utoipa::path(
189    get,
190    path = "/v1/agent/shared_mem",
191    responses(
192        (status = 200, description = "Shared memory info", body = HashMap<String, HashMap<String, serde_json::Value>>),
193        (status = 500, description = "Failed to get shared memory info")
194    ),
195    tag = "agent"
196)]
197pub async fn get_shared_memory(
198    State(state): State<ApiState>,
199) -> ApiResult<Json<HashMap<String, HashMap<String, serde_json::Value>>>> {
200    let agent_service = state
201        .agent_service
202        .as_ref()
203        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
204
205    match agent_service.get_shared_memory_info().await {
206        Ok(shm_info) => Ok(Json(shm_info)),
207        Err(e) => Err(ApiError::internal(format!(
208            "Failed to get shared memory info: {}",
209            e
210        ))),
211    }
212}
213
214/// Deregister an agent from FEAGI and clean up its resources.
215#[utoipa::path(
216    delete,
217    path = "/v1/agent/deregister",
218    request_body = AgentDeregistrationRequest,
219    responses(
220        (status = 200, description = "Agent deregistered successfully", body = SuccessResponse),
221        (status = 500, description = "Deregistration failed")
222    ),
223    tag = "agent"
224)]
225pub async fn deregister_agent(
226    State(state): State<ApiState>,
227    Json(request): Json<AgentDeregistrationRequest>,
228) -> ApiResult<Json<SuccessResponse>> {
229    let agent_service = state
230        .agent_service
231        .as_ref()
232        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
233
234    match agent_service.deregister_agent(&request.agent_id).await {
235        Ok(_) => Ok(Json(SuccessResponse {
236            message: format!("Agent '{}' deregistered successfully", request.agent_id),
237            success: Some(true),
238        })),
239        Err(e) => Err(ApiError::internal(format!("Deregistration failed: {}", e))),
240    }
241}
242
243/// Manually stimulate neurons at specific coordinates across multiple cortical areas for testing and debugging.
244#[utoipa::path(
245    post,
246    path = "/v1/agent/manual_stimulation",
247    request_body = ManualStimulationRequest,
248    responses(
249        (status = 200, description = "Manual stimulation result", body = ManualStimulationResponse),
250        (status = 500, description = "Stimulation failed")
251    ),
252    tag = "agent"
253)]
254pub async fn manual_stimulation(
255    State(state): State<ApiState>,
256    Json(request): Json<ManualStimulationRequest>,
257) -> ApiResult<Json<ManualStimulationResponse>> {
258    let agent_service = state
259        .agent_service
260        .as_ref()
261        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
262
263    // Ensure runtime service is connected to agent service (if not already connected)
264    // This allows runtime_service to be set after AgentServiceImpl is wrapped in Arc
265    agent_service.try_set_runtime_service(state.runtime_service.clone());
266
267    match agent_service
268        .manual_stimulation(request.stimulation_payload)
269        .await
270    {
271        Ok(result) => {
272            let success = result
273                .get("success")
274                .and_then(|v| v.as_bool())
275                .unwrap_or(false);
276            let total_coordinates = result
277                .get("total_coordinates")
278                .and_then(|v| v.as_u64())
279                .unwrap_or(0) as usize;
280            let successful_areas = result
281                .get("successful_areas")
282                .and_then(|v| v.as_u64())
283                .unwrap_or(0) as usize;
284            let failed_areas = result
285                .get("failed_areas")
286                .and_then(|v| v.as_array())
287                .map(|arr| {
288                    arr.iter()
289                        .filter_map(|v| v.as_str().map(String::from))
290                        .collect()
291                })
292                .unwrap_or_default();
293            let error = result
294                .get("error")
295                .and_then(|v| v.as_str())
296                .map(String::from);
297
298            Ok(Json(ManualStimulationResponse {
299                success,
300                total_coordinates,
301                successful_areas,
302                failed_areas,
303                error,
304            }))
305        }
306        Err(e) => Err(ApiError::internal(format!("Stimulation failed: {}", e))),
307    }
308}
309
310/// Get Fire Queue (FQ) sampler coordination status including visualization and motor sampling configuration.
311#[utoipa::path(
312    get,
313    path = "/v1/agent/fq_sampler_status",
314    responses(
315        (status = 200, description = "FQ sampler status", body = HashMap<String, serde_json::Value>),
316        (status = 500, description = "Failed to get FQ sampler status")
317    ),
318    tag = "agent"
319)]
320pub async fn get_fq_sampler_status(
321    State(state): State<ApiState>,
322) -> ApiResult<Json<HashMap<String, serde_json::Value>>> {
323    let agent_service = state
324        .agent_service
325        .as_ref()
326        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
327
328    let runtime_service = state.runtime_service.as_ref();
329
330    // Get all agents
331    let agent_ids = agent_service
332        .list_agents()
333        .await
334        .map_err(|e| ApiError::internal(format!("Failed to list agents: {}", e)))?;
335
336    // Get FCL sampler config from RuntimeService
337    let (fcl_frequency, fcl_consumer) = runtime_service
338        .get_fcl_sampler_config()
339        .await
340        .map_err(|e| ApiError::internal(format!("Failed to get sampler config: {}", e)))?;
341
342    // Build response matching Python structure
343    let mut visualization_agents = Vec::new();
344    let mut motor_agents = Vec::new();
345
346    for agent_id in &agent_ids {
347        if let Ok(props) = agent_service.get_agent_properties(agent_id).await {
348            if props.capabilities.contains_key("visualization") {
349                visualization_agents.push(agent_id.clone());
350            }
351            if props.capabilities.contains_key("motor") {
352                motor_agents.push(agent_id.clone());
353            }
354        }
355    }
356
357    let mut fq_coordination = HashMap::new();
358
359    let mut viz_sampler = HashMap::new();
360    viz_sampler.insert(
361        "enabled".to_string(),
362        serde_json::json!(!visualization_agents.is_empty()),
363    );
364    viz_sampler.insert(
365        "reason".to_string(),
366        serde_json::json!(if visualization_agents.is_empty() {
367            "No visualization agents connected".to_string()
368        } else {
369            format!(
370                "{} visualization agent(s) connected",
371                visualization_agents.len()
372            )
373        }),
374    );
375    viz_sampler.insert(
376        "agents_requiring".to_string(),
377        serde_json::json!(visualization_agents),
378    );
379    viz_sampler.insert("frequency_hz".to_string(), serde_json::json!(fcl_frequency));
380    fq_coordination.insert(
381        "visualization_fq_sampler".to_string(),
382        serde_json::json!(viz_sampler),
383    );
384
385    let mut motor_sampler = HashMap::new();
386    motor_sampler.insert(
387        "enabled".to_string(),
388        serde_json::json!(!motor_agents.is_empty()),
389    );
390    motor_sampler.insert(
391        "reason".to_string(),
392        serde_json::json!(if motor_agents.is_empty() {
393            "No motor agents connected".to_string()
394        } else {
395            format!("{} motor agent(s) connected", motor_agents.len())
396        }),
397    );
398    motor_sampler.insert(
399        "agents_requiring".to_string(),
400        serde_json::json!(motor_agents),
401    );
402    motor_sampler.insert("frequency_hz".to_string(), serde_json::json!(100.0));
403    fq_coordination.insert(
404        "motor_fq_sampler".to_string(),
405        serde_json::json!(motor_sampler),
406    );
407
408    let mut response = HashMap::new();
409    response.insert(
410        "fq_sampler_coordination".to_string(),
411        serde_json::json!(fq_coordination),
412    );
413    response.insert(
414        "agent_registry".to_string(),
415        serde_json::json!({
416            "total_agents": agent_ids.len(),
417            "agent_ids": agent_ids
418        }),
419    );
420    response.insert(
421        "system_status".to_string(),
422        serde_json::json!("coordinated_via_registration_manager"),
423    );
424    response.insert(
425        "fcl_sampler_consumer".to_string(),
426        serde_json::json!(fcl_consumer),
427    );
428
429    Ok(Json(response))
430}
431
432/// Get list of all supported agent types and capability types (sensory, motor, visualization, etc.).
433#[utoipa::path(
434    get,
435    path = "/v1/agent/capabilities",
436    responses(
437        (status = 200, description = "List of capabilities", body = HashMap<String, Vec<String>>),
438        (status = 500, description = "Failed to get capabilities")
439    ),
440    tag = "agent"
441)]
442pub async fn get_capabilities(
443    State(_state): State<ApiState>,
444) -> ApiResult<Json<HashMap<String, Vec<String>>>> {
445    let mut response = HashMap::new();
446    response.insert(
447        "agent_types".to_string(),
448        vec![
449            "sensory".to_string(),
450            "motor".to_string(),
451            "both".to_string(),
452            "visualization".to_string(),
453            "infrastructure".to_string(),
454        ],
455    );
456    response.insert(
457        "capability_types".to_string(),
458        vec![
459            "vision".to_string(),
460            "motor".to_string(),
461            "visualization".to_string(),
462            "sensory".to_string(),
463        ],
464    );
465
466    Ok(Json(response))
467}
468
469/// Get comprehensive agent information including status, capabilities, version, and connection details.
470#[utoipa::path(
471    get,
472    path = "/v1/agent/info/{agent_id}",
473    params(
474        ("agent_id" = String, Path, description = "Agent ID")
475    ),
476    responses(
477        (status = 200, description = "Agent detailed info", body = HashMap<String, serde_json::Value>),
478        (status = 404, description = "Agent not found"),
479        (status = 500, description = "Failed to get agent info")
480    ),
481    tag = "agent"
482)]
483pub async fn get_agent_info(
484    State(state): State<ApiState>,
485    Path(agent_id): Path<String>,
486) -> ApiResult<Json<HashMap<String, serde_json::Value>>> {
487    let agent_service = state
488        .agent_service
489        .as_ref()
490        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
491
492    let properties = agent_service
493        .get_agent_properties(&agent_id)
494        .await
495        .map_err(|e| ApiError::not_found("agent", &e.to_string()))?;
496
497    let mut response = HashMap::new();
498    response.insert("agent_id".to_string(), serde_json::json!(agent_id));
499    response.insert(
500        "agent_type".to_string(),
501        serde_json::json!(properties.agent_type),
502    );
503    response.insert(
504        "agent_ip".to_string(),
505        serde_json::json!(properties.agent_ip),
506    );
507    response.insert(
508        "agent_data_port".to_string(),
509        serde_json::json!(properties.agent_data_port),
510    );
511    response.insert(
512        "capabilities".to_string(),
513        serde_json::json!(properties.capabilities),
514    );
515    response.insert(
516        "agent_version".to_string(),
517        serde_json::json!(properties.agent_version),
518    );
519    response.insert(
520        "controller_version".to_string(),
521        serde_json::json!(properties.controller_version),
522    );
523    response.insert("status".to_string(), serde_json::json!("active"));
524    if let Some(ref transport) = properties.chosen_transport {
525        response.insert("chosen_transport".to_string(), serde_json::json!(transport));
526    }
527
528    Ok(Json(response))
529}
530
531/// Get agent properties using path parameter. Same as /v1/agent/properties but with agent_id in the URL path.
532#[utoipa::path(
533    get,
534    path = "/v1/agent/properties/{agent_id}",
535    params(
536        ("agent_id" = String, Path, description = "Agent ID")
537    ),
538    responses(
539        (status = 200, description = "Agent properties", body = AgentPropertiesResponse),
540        (status = 404, description = "Agent not found"),
541        (status = 500, description = "Failed to get agent properties")
542    ),
543    tag = "agent"
544)]
545pub async fn get_agent_properties_path(
546    State(state): State<ApiState>,
547    Path(agent_id): Path<String>,
548) -> ApiResult<Json<AgentPropertiesResponse>> {
549    let agent_service = state
550        .agent_service
551        .as_ref()
552        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
553
554    match agent_service.get_agent_properties(&agent_id).await {
555        Ok(properties) => Ok(Json(AgentPropertiesResponse {
556            agent_type: properties.agent_type,
557            agent_ip: properties.agent_ip,
558            agent_data_port: properties.agent_data_port,
559            agent_router_address: properties.agent_router_address,
560            agent_version: properties.agent_version,
561            controller_version: properties.controller_version,
562            capabilities: properties.capabilities,
563            chosen_transport: properties.chosen_transport,
564        })),
565        Err(e) => Err(ApiError::not_found("agent", &format!("{}", e))),
566    }
567}
568
569/// Configure agent parameters and settings. (Not yet implemented)
570#[utoipa::path(
571    post,
572    path = "/v1/agent/configure",
573    responses(
574        (status = 200, description = "Agent configured", body = HashMap<String, String>),
575        (status = 400, description = "Invalid input"),
576        (status = 500, description = "Failed to configure agent")
577    ),
578    tag = "agent"
579)]
580pub async fn post_configure(
581    State(_state): State<ApiState>,
582    Json(config): Json<HashMap<String, serde_json::Value>>,
583) -> ApiResult<Json<HashMap<String, String>>> {
584    tracing::info!(target: "feagi-api", "Agent configuration requested: {} params", config.len());
585
586    Ok(Json(HashMap::from([
587        (
588            "message".to_string(),
589            "Agent configuration updated".to_string(),
590        ),
591        ("status".to_string(), "not_yet_implemented".to_string()),
592    ])))
593}