Skip to main content

feagi_api/endpoints/
outputs.rs

1// Copyright 2025 Neuraville Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5 * FEAGI v1 Outputs API
6 *
7 * Endpoints for output/motor target configuration
8 * Maps to Python: feagi/api/v1/outputs.py
9 */
10
11use crate::common::ApiState;
12use crate::common::{ApiError, ApiResult, Json, State};
13// Removed - using crate::common::State instead
14use serde::Serialize;
15use serde_json::{json, Value};
16use std::collections::HashMap;
17
18// ============================================================================
19// OUTPUT TARGETS
20// ============================================================================
21
22/// Get available output targets from connected motor/output agents.
23#[utoipa::path(
24    get,
25    path = "/v1/output/targets",
26    tag = "outputs",
27    responses(
28        (status = 200, description = "Output targets", body = HashMap<String, serde_json::Value>),
29        (status = 500, description = "Internal server error")
30    )
31)]
32pub async fn get_targets(State(state): State<ApiState>) -> ApiResult<Json<HashMap<String, Value>>> {
33    // Get motor/output capable agents from PNS
34    let agent_service = state
35        .agent_service
36        .as_ref()
37        .ok_or_else(|| ApiError::internal("Agent service not available"))?;
38
39    let agent_ids = agent_service
40        .list_agents()
41        .await
42        .map_err(|e| ApiError::internal(format!("Failed to list agents: {}", e)))?;
43
44    // Filter for agents with motor/output capabilities
45    let mut motor_agents = Vec::new();
46    for agent_id in agent_ids {
47        // Get agent properties to check capabilities
48        if let Ok(props) = agent_service.get_agent_properties(&agent_id).await {
49            // Check if agent has motor capabilities
50            if props.capabilities.contains_key("motor")
51                || props.capabilities.contains_key("output")
52                || props.agent_type.to_lowercase().contains("motor")
53            {
54                motor_agents.push(agent_id);
55            }
56        }
57    }
58
59    let mut response = HashMap::new();
60    response.insert("targets".to_string(), json!(motor_agents));
61
62    Ok(Json(response))
63}
64
65/// Configure output targets and motor agent connections.
66#[utoipa::path(
67    post,
68    path = "/v1/output/configure",
69    tag = "outputs",
70    responses(
71        (status = 200, description = "Outputs configured", body = HashMap<String, String>),
72        (status = 500, description = "Internal server error")
73    )
74)]
75pub async fn post_configure(
76    State(_state): State<ApiState>,
77    Json(request): Json<HashMap<String, Value>>,
78) -> ApiResult<Json<HashMap<String, String>>> {
79    // Extract configuration from request
80    let config = request
81        .get("config")
82        .ok_or_else(|| ApiError::invalid_input("Missing 'config' field"))?;
83
84    // TODO: Store output configuration in runtime state
85    // For now, just validate the structure
86    if !config.is_object() {
87        return Err(ApiError::invalid_input("'config' must be an object"));
88    }
89
90    tracing::info!(target: "feagi-api", "Output configuration updated: {} targets",
91        config.as_object().map(|o| o.len()).unwrap_or(0));
92
93    Ok(Json(HashMap::from([(
94        "message".to_string(),
95        "Outputs configured successfully".to_string(),
96    )])))
97}
98
99// ============================================================================
100// MOTOR OUTPUT SNAPSHOT (runtime tap)
101// ============================================================================
102
103/// Single voxel sample in a motor activity area.
104#[derive(Serialize, Clone, Debug, utoipa::ToSchema)]
105pub struct MotorTapSample {
106    pub x: u32,
107    pub y: u32,
108    pub z: u32,
109    pub potential: f32,
110}
111
112/// Per-cortical-area activity captured by the motor tap.
113#[derive(Serialize, Clone, Debug, utoipa::ToSchema)]
114pub struct MotorTapArea {
115    pub cortical_id: String,
116    pub cortical_idx: u32,
117    pub neuron_count: usize,
118    pub samples: Vec<MotorTapSample>,
119}
120
121/// Per-agent publish stats captured by the motor tap.
122#[derive(Serialize, Clone, Debug, utoipa::ToSchema)]
123pub struct MotorTapAgent {
124    pub agent_id: String,
125    pub burst_num: u64,
126    pub timestamp_ms: i64,
127    pub byte_count: usize,
128    pub published: bool,
129    pub last_error: String,
130    pub subscribed_cortical_ids: Vec<String>,
131}
132
133/// Response payload for `GET /v1/output/motor_snapshot/last`.
134#[derive(Serialize, Clone, Debug, utoipa::ToSchema)]
135pub struct MotorSnapshotResponse {
136    /// Burst counter when the area snapshot was captured. Zero if no motor activity
137    /// has been recorded since FEAGI started.
138    pub burst_num: u64,
139    /// Wall-clock millisecond timestamp when the snapshot was captured.
140    pub timestamp_ms: i64,
141    /// Convenience flag for clients - true when at least one area was captured.
142    pub has_data: bool,
143    /// Total motor cortical areas seen this burst (before per-agent filtering).
144    pub total_areas: usize,
145    /// Total firing neurons across all motor areas this burst.
146    pub total_neurons: usize,
147    /// Per-area activity, ordered as captured by the burst loop.
148    pub areas: Vec<MotorTapArea>,
149    /// Per-agent publish summary. Empty when no agents have published since FEAGI start.
150    pub agents: Vec<MotorTapAgent>,
151}
152
153/// Get the most recent motor output produced by the burst loop.
154///
155/// This taps directly into the motor pipeline before per-agent transport
156/// filtering, so debuggers can confirm OPU activity even when no embodiment is
157/// connected. The `agents` array shows what was actually published per agent.
158///
159/// Optional `agent_id` filters the `agents` list. Optional `cortical_id` keeps
160/// only the matching OPU in `areas` and recomputes `total_*` (same base64 as
161/// ``MotorTapArea.cortical_id`` in JSON responses).
162#[utoipa::path(
163    get,
164    path = "/v1/output/motor_snapshot/last",
165    tag = "outputs",
166    params(
167        ("agent_id" = Option<String>, Query, description = "Filter agents by id"),
168        ("cortical_id" = Option<String>, Query, description = "Filter motor areas to one cortical id (base64)")
169    ),
170    responses(
171        (status = 200, description = "Latest motor pipeline snapshot", body = MotorSnapshotResponse),
172        (status = 500, description = "Internal server error")
173    )
174)]
175pub async fn get_motor_snapshot_last(
176    State(_state): State<ApiState>,
177    axum::extract::Query(query): axum::extract::Query<HashMap<String, String>>,
178) -> ApiResult<Json<MotorSnapshotResponse>> {
179    let snap = feagi_npu_burst_engine::BurstTaps::instance().motor_snapshot();
180    let agent_filter = query.get("agent_id").cloned();
181    let area_filter = query.get("cortical_id").cloned();
182
183    let mut areas: Vec<MotorTapArea> = snap
184        .areas
185        .into_iter()
186        .map(|a| MotorTapArea {
187            cortical_id: a.cortical_id,
188            cortical_idx: a.cortical_idx,
189            neuron_count: a.neuron_count,
190            samples: a
191                .samples
192                .into_iter()
193                .map(|s| MotorTapSample {
194                    x: s.x,
195                    y: s.y,
196                    z: s.z,
197                    potential: s.potential,
198                })
199                .collect(),
200        })
201        .collect();
202
203    if let Some(ref cid) = area_filter {
204        if !cid.is_empty() {
205            areas.retain(|a| a.cortical_id == *cid);
206        }
207    }
208
209    let total_areas = areas.len();
210    let total_neurons: usize = areas.iter().map(|a| a.neuron_count).sum();
211    let has_data = total_areas > 0 && snap.burst_num > 0;
212
213    let mut agents: Vec<MotorTapAgent> = snap
214        .per_agent
215        .into_iter()
216        .filter(|(id, _)| match &agent_filter {
217            Some(filter) => filter == id,
218            None => true,
219        })
220        .map(|(agent_id, stats)| MotorTapAgent {
221            agent_id,
222            burst_num: stats.burst_num,
223            timestamp_ms: stats.timestamp_ms,
224            byte_count: stats.byte_count,
225            published: stats.published,
226            last_error: stats.last_error,
227            subscribed_cortical_ids: stats.subscribed_cortical_ids,
228        })
229        .collect();
230    agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
231
232    Ok(Json(MotorSnapshotResponse {
233        burst_num: snap.burst_num,
234        timestamp_ms: snap.timestamp_ms,
235        has_data,
236        total_areas,
237        total_neurons,
238        areas,
239        agents,
240    }))
241}