Skip to main content

mnemo_rest/
handlers.rs

1use std::sync::Arc;
2
3use axum::Json;
4use axum::extract::{Path, Query, State};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use serde::Deserialize;
8use uuid::Uuid;
9
10use mnemo_core::error::Error as CoreError;
11use mnemo_core::hash::compute_content_hash;
12use mnemo_core::model::acl::Permission;
13use mnemo_core::model::delegation::{Delegation, DelegationScope};
14use mnemo_core::model::event::{AgentEvent, EventType};
15use mnemo_core::model::memory::{MemoryType, Scope};
16use mnemo_core::query::MnemoEngine;
17use mnemo_core::query::branch::{BranchRequest, BranchResponse};
18use mnemo_core::query::checkpoint::{CheckpointRequest, CheckpointResponse};
19use mnemo_core::query::consolidate::{ConsolidateRequest, ConsolidateResponse};
20use mnemo_core::query::forget::{
21    ForgetRequest, ForgetResponse, ForgetStrategy, ForgetSubjectRequest, ForgetSubjectResponse,
22};
23use mnemo_core::query::merge::{MergeRequest, MergeResponse};
24use mnemo_core::query::recall::{RecallRequest, RecallResponse};
25use mnemo_core::query::remember::{RememberRequest, RememberResponse};
26use mnemo_core::query::replay::{ReplayRequest, ReplayResponse};
27use mnemo_core::query::share::{ShareRequest, ShareResponse};
28
29type AppState = Arc<MnemoEngine>;
30
31// ---------------------------------------------------------------------------
32// Error handling
33// ---------------------------------------------------------------------------
34
35pub struct AppError(CoreError);
36
37impl IntoResponse for AppError {
38    fn into_response(self) -> Response {
39        let (status, msg) = match &self.0 {
40            CoreError::Validation(m) => (StatusCode::BAD_REQUEST, m.clone()),
41            CoreError::PermissionDenied(m) => (StatusCode::FORBIDDEN, m.clone()),
42            CoreError::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()),
43            other => {
44                tracing::error!("internal error: {other}");
45                (
46                    StatusCode::INTERNAL_SERVER_ERROR,
47                    "internal server error".to_string(),
48                )
49            }
50        };
51        (status, Json(serde_json::json!({"error": msg}))).into_response()
52    }
53}
54
55impl From<CoreError> for AppError {
56    fn from(e: CoreError) -> Self {
57        AppError(e)
58    }
59}
60
61// ---------------------------------------------------------------------------
62// Query / body helper structs
63// ---------------------------------------------------------------------------
64
65#[derive(Debug, Deserialize)]
66pub struct RecallParams {
67    pub query: String,
68    pub agent_id: Option<String>,
69    pub limit: Option<usize>,
70    pub memory_type: Option<String>,
71    pub scope: Option<String>,
72    pub min_importance: Option<f32>,
73    pub tags: Option<String>,
74    pub org_id: Option<String>,
75    pub strategy: Option<String>,
76    pub as_of: Option<String>,
77    pub memory_types: Option<String>,
78    pub hybrid_weights: Option<String>,
79    pub rrf_k: Option<f32>,
80    pub explain: Option<bool>,
81    /// v0.4.7 — opt-in current-fact resolver. Set the metadata key
82    /// to scope fact identity by (typical: `fact_id`). When set,
83    /// the response returns the most-recent write per fact group.
84    pub current_fact_key: Option<String>,
85    /// v0.4.7 — include the supersession chain in the response.
86    /// Honored only when `current_fact_key` is also set.
87    pub current_fact_include_chain: Option<bool>,
88    /// v0.4.8 — opt-in orientation cache. When `true` AND the
89    /// engine has an `OrientationCacheStore` attached, the recall
90    /// updates a per-namespace, constant-token "context map" and
91    /// returns a bounded rendering in
92    /// `response.orientation_cache`. PEEK-anchored
93    /// (arXiv:2605.19932).
94    pub orientation_cache: Option<bool>,
95    /// v0.4.8 — explicit namespace label. When omitted, the engine
96    /// derives one from `(org_id, agent_id)`.
97    pub orientation_namespace: Option<String>,
98    /// v0.4.8 — token budget for the rendered map. Defaults to
99    /// 512 when omitted.
100    pub orientation_token_budget: Option<u32>,
101    /// v0.4.8 — include the rendered map in the response.
102    /// Defaults to `true` when omitted.
103    pub orientation_include_in_response: Option<bool>,
104    /// v0.4.8 — run the Distiller and update the in-process store.
105    /// Defaults to `true` when omitted; set to `false` for warm-up
106    /// or inspection calls that should not mutate the map.
107    pub orientation_distill: Option<bool>,
108}
109
110#[derive(Debug, Deserialize)]
111pub struct ForgetParams {
112    pub strategy: Option<String>,
113    pub agent_id: Option<String>,
114}
115
116#[derive(Debug, Deserialize)]
117pub struct ShareBody {
118    pub target_agent_id: String,
119    pub target_agent_ids: Option<Vec<String>>,
120    pub permission: Option<String>,
121    pub expires_in_hours: Option<f64>,
122    pub agent_id: Option<String>,
123}
124
125#[derive(Debug, Deserialize)]
126pub struct VerifyBody {
127    pub agent_id: Option<String>,
128    pub thread_id: Option<String>,
129}
130
131#[derive(Debug, Deserialize)]
132pub struct TrajectoryAuditBody {
133    pub agent_id: Option<String>,
134    pub thread_id: Option<String>,
135    pub active_bank_ceiling: Option<usize>,
136    pub fact_key: Option<String>,
137    pub named_forget_strategies: Option<Vec<String>>,
138}
139
140#[derive(Debug, Deserialize)]
141pub struct DelegateRequest {
142    pub delegate_id: String,
143    pub permission: String,
144    pub memory_ids: Option<Vec<String>>,
145    pub tags: Option<Vec<String>>,
146    pub max_depth: Option<u32>,
147    pub expires_in_hours: Option<f64>,
148    /// The agent requesting delegation. Required — the server will verify
149    /// this agent has `Delegate` permission on the target memories.
150    pub agent_id: Option<String>,
151}
152
153// ---------------------------------------------------------------------------
154// Handlers
155// ---------------------------------------------------------------------------
156
157/// POST /v1/memories -- store a new memory.
158pub async fn remember_handler(
159    State(engine): State<AppState>,
160    Json(request): Json<RememberRequest>,
161) -> Result<Json<RememberResponse>, AppError> {
162    let response = engine.remember(request).await?;
163    Ok(Json(response))
164}
165
166/// GET /v1/memories?query=...&limit=...&memory_type=...&scope=...&strategy=...
167pub async fn recall_handler(
168    State(engine): State<AppState>,
169    Query(params): Query<RecallParams>,
170) -> Result<Json<RecallResponse>, AppError> {
171    let memory_type = match params.memory_type.as_deref() {
172        Some(s) => Some(s.parse::<MemoryType>().map_err(|_| {
173            AppError(CoreError::Validation(format!(
174                "invalid memory_type '{}': expected one of: episodic, semantic, procedural, working",
175                s
176            )))
177        })?),
178        None => None,
179    };
180
181    let scope = match params.scope.as_deref() {
182        Some(s) => Some(s.parse::<Scope>().map_err(|_| {
183            AppError(CoreError::Validation(format!(
184                "invalid scope '{}': expected one of: private, shared, public, global",
185                s
186            )))
187        })?),
188        None => None,
189    };
190
191    let tags = params.tags.as_deref().map(|t| {
192        t.split(',')
193            .map(|s| s.trim().to_string())
194            .collect::<Vec<_>>()
195    });
196
197    let memory_types = match params.memory_types.as_deref() {
198        Some(s) => {
199            let mut parsed = Vec::new();
200            for t in s.split(',') {
201                let trimmed = t.trim();
202                let mt = trimmed.parse::<MemoryType>().map_err(|_| {
203                    AppError(CoreError::Validation(format!(
204                        "invalid memory_type '{}' in memory_types: expected one of: episodic, semantic, procedural, working",
205                        trimmed
206                    )))
207                })?;
208                parsed.push(mt);
209            }
210            Some(parsed)
211        }
212        None => None,
213    };
214
215    let hybrid_weights = match params.hybrid_weights.as_deref() {
216        Some(s) => {
217            let mut weights = Vec::new();
218            for w in s.split(',') {
219                let trimmed = w.trim();
220                let val = trimmed.parse::<f32>().map_err(|_| {
221                    AppError(CoreError::Validation(format!(
222                        "invalid weight '{}' in hybrid_weights: expected a floating-point number",
223                        trimmed
224                    )))
225                })?;
226                weights.push(val);
227            }
228            Some(weights)
229        }
230        None => None,
231    };
232
233    let request = RecallRequest {
234        query: params.query,
235        agent_id: params.agent_id,
236        limit: params.limit,
237        memory_type,
238        memory_types,
239        scope,
240        min_importance: params.min_importance,
241        tags,
242        org_id: params.org_id,
243        strategy: params.strategy,
244        temporal_range: None,
245        recency_half_life_hours: None,
246        hybrid_weights,
247        rrf_k: params.rrf_k,
248        as_of: params.as_of,
249        explain: params.explain,
250        with_provenance: None,
251        mode: None,
252        current_fact_resolver: params.current_fact_key.map(|fact_key| {
253            mnemo_core::query::current_fact_resolver::CurrentFactResolverConfig {
254                fact_key,
255                include_supersession_chain: params.current_fact_include_chain.unwrap_or(false),
256            }
257        }),
258        orientation_cache: if params.orientation_cache.unwrap_or(false) {
259            Some(
260                mnemo_core::query::orientation_cache::OrientationCacheConfig {
261                    namespace: params.orientation_namespace.clone(),
262                    token_budget: params.orientation_token_budget,
263                    include_in_response: params.orientation_include_in_response.unwrap_or(true),
264                    distill: params.orientation_distill.unwrap_or(true),
265                },
266            )
267        } else {
268            None
269        },
270        evidence_budget: None,
271        retained_token_budget: None,
272        domain_scope: None,
273        reasoning_trust: None,
274    };
275
276    let response = engine.recall(request).await?;
277    Ok(Json(response))
278}
279
280/// GET /v1/memories/:id -- retrieve a single memory by UUID.
281pub async fn get_memory_handler(
282    State(engine): State<AppState>,
283    Path(id): Path<Uuid>,
284) -> Result<Json<serde_json::Value>, AppError> {
285    let record = engine
286        .storage
287        .get_memory(id)
288        .await?
289        .ok_or_else(|| CoreError::NotFound(format!("memory {id} not found")))?;
290
291    let value = serde_json::json!({
292        "id": record.id,
293        "agent_id": record.agent_id,
294        "content": record.content,
295        "memory_type": record.memory_type,
296        "scope": record.scope,
297        "importance": record.importance,
298        "tags": record.tags,
299        "metadata": record.metadata,
300        "source_type": record.source_type,
301        "source_id": record.source_id,
302        "consolidation_state": record.consolidation_state,
303        "access_count": record.access_count,
304        "org_id": record.org_id,
305        "thread_id": record.thread_id,
306        "created_at": record.created_at,
307        "updated_at": record.updated_at,
308        "last_accessed_at": record.last_accessed_at,
309        "expires_at": record.expires_at,
310        "deleted_at": record.deleted_at,
311        "decay_rate": record.decay_rate,
312        "created_by": record.created_by,
313        "version": record.version,
314        "prev_version_id": record.prev_version_id,
315        "quarantined": record.quarantined,
316        "quarantine_reason": record.quarantine_reason,
317    });
318
319    Ok(Json(value))
320}
321
322/// DELETE /v1/memories/:id?strategy=soft_delete|hard_delete|decay|consolidate|archive
323pub async fn forget_handler(
324    State(engine): State<AppState>,
325    Path(id): Path<Uuid>,
326    Query(params): Query<ForgetParams>,
327) -> Result<Json<ForgetResponse>, AppError> {
328    let strategy = match params.strategy.as_deref() {
329        Some(s) => Some(match s {
330            "soft_delete" => ForgetStrategy::SoftDelete,
331            "hard_delete" => ForgetStrategy::HardDelete,
332            "decay" => ForgetStrategy::Decay,
333            "consolidate" => ForgetStrategy::Consolidate,
334            "archive" => ForgetStrategy::Archive,
335            "redact" => ForgetStrategy::Redact,
336            other => {
337                return Err(AppError(CoreError::Validation(format!(
338                    "invalid forget strategy '{}': expected one of: soft_delete, hard_delete, decay, consolidate, archive, redact",
339                    other
340                ))));
341            }
342        }),
343        None => None,
344    };
345
346    let request = ForgetRequest {
347        memory_ids: vec![id],
348        agent_id: params.agent_id,
349        strategy,
350        criteria: None,
351    };
352
353    let response = engine.forget(request).await?;
354    Ok(Json(response))
355}
356
357#[derive(Debug, Deserialize)]
358pub struct ForgetSubjectBody {
359    pub subject_id: String,
360    pub strategy: Option<String>,
361    pub agent_id: Option<String>,
362}
363
364/// POST /v1/forget_subject — GDPR / DPDPA-aligned subject erasure.
365pub async fn forget_subject_handler(
366    State(engine): State<AppState>,
367    Json(body): Json<ForgetSubjectBody>,
368) -> Result<Json<ForgetSubjectResponse>, AppError> {
369    let strategy = match body.strategy.as_deref().unwrap_or("redact") {
370        "redact" => ForgetStrategy::Redact,
371        "hard_delete" => ForgetStrategy::HardDelete,
372        "soft_delete" => ForgetStrategy::SoftDelete,
373        other => {
374            return Err(AppError(CoreError::Validation(format!(
375                "invalid forget_subject strategy '{}': expected one of: redact, hard_delete, soft_delete",
376                other
377            ))));
378        }
379    };
380
381    let request = ForgetSubjectRequest {
382        subject_id: body.subject_id,
383        agent_id: body.agent_id,
384        strategy,
385    };
386
387    let response = engine.forget_subject(request).await?;
388    Ok(Json(response))
389}
390
391/// POST /v1/memories/:id/share
392pub async fn share_handler(
393    State(engine): State<AppState>,
394    Path(id): Path<Uuid>,
395    Json(body): Json<ShareBody>,
396) -> Result<Json<ShareResponse>, AppError> {
397    let permission = match body.permission.as_deref() {
398        Some(s) => Some(s.parse::<Permission>().map_err(|_| {
399            AppError(CoreError::Validation(format!(
400                "invalid permission '{}': expected one of: read, write, delete, share, delegate, admin",
401                s
402            )))
403        })?),
404        None => None,
405    };
406
407    let request = ShareRequest {
408        memory_id: id,
409        agent_id: body.agent_id,
410        target_agent_id: body.target_agent_id,
411        target_agent_ids: body.target_agent_ids,
412        permission,
413        expires_in_hours: body.expires_in_hours,
414    };
415
416    let response = engine.share(request).await?;
417    Ok(Json(response))
418}
419
420/// POST /v1/checkpoints
421pub async fn checkpoint_handler(
422    State(engine): State<AppState>,
423    Json(request): Json<CheckpointRequest>,
424) -> Result<Json<CheckpointResponse>, AppError> {
425    let response = engine.checkpoint(request).await?;
426    Ok(Json(response))
427}
428
429/// POST /v1/consolidate
430pub async fn consolidate_handler(
431    State(engine): State<AppState>,
432    Json(request): Json<ConsolidateRequest>,
433) -> Result<Json<ConsolidateResponse>, AppError> {
434    let response = engine.consolidate(request).await?;
435    Ok(Json(response))
436}
437
438/// POST /v1/branches
439pub async fn branch_handler(
440    State(engine): State<AppState>,
441    Json(request): Json<BranchRequest>,
442) -> Result<Json<BranchResponse>, AppError> {
443    let response = engine.branch(request).await?;
444    Ok(Json(response))
445}
446
447/// POST /v1/merge
448pub async fn merge_handler(
449    State(engine): State<AppState>,
450    Json(request): Json<MergeRequest>,
451) -> Result<Json<MergeResponse>, AppError> {
452    let response = engine.merge(request).await?;
453    Ok(Json(response))
454}
455
456/// POST /v1/replay
457pub async fn replay_handler(
458    State(engine): State<AppState>,
459    Json(request): Json<ReplayRequest>,
460) -> Result<Json<ReplayResponse>, AppError> {
461    let response = engine.replay(request).await?;
462    Ok(Json(response))
463}
464
465/// POST /v1/verify -- verify hash chain integrity.
466pub async fn verify_handler(
467    State(engine): State<AppState>,
468    Json(body): Json<VerifyBody>,
469) -> Result<Json<serde_json::Value>, AppError> {
470    let result = engine
471        .verify_integrity(body.agent_id, body.thread_id.as_deref())
472        .await?;
473
474    let response = serde_json::json!({
475        "valid": result.valid,
476        "total_records": result.total_records,
477        "verified_records": result.verified_records,
478        "first_broken_at": result.first_broken_at.map(|id| id.to_string()),
479        "error_message": result.error_message,
480        "status": if result.valid { "verified" } else { "integrity_violation" },
481    });
482
483    Ok(Json(response))
484}
485
486/// POST /v1/compliance/trajectory_audit -- GEM-aligned trajectory audit.
487///
488/// Anchor: arXiv:2605.26252. Complements `/v1/verify` (per-record chain
489/// integrity) on the orthogonal trajectory-correctness axis.
490pub async fn trajectory_audit_handler(
491    State(engine): State<AppState>,
492    Json(body): Json<TrajectoryAuditBody>,
493) -> Result<Json<serde_json::Value>, AppError> {
494    let agent_id = body
495        .agent_id
496        .clone()
497        .unwrap_or_else(|| engine.default_agent_id.clone());
498
499    // Mirror verify_handler's storage fetch shape: list_events returns
500    // DESC order; the trajectory audit needs chronological order.
501    let mut events = engine
502        .storage
503        .list_events(&agent_id, mnemo_core::query::MAX_BATCH_QUERY_LIMIT, 0)
504        .await?;
505    events.reverse();
506
507    let mut req = mnemo_compliance::trajectory::TrajectoryAuditRequest {
508        agent_id: Some(agent_id),
509        thread_id: body.thread_id.clone(),
510        ..Default::default()
511    };
512    if let Some(c) = body.active_bank_ceiling {
513        req.active_bank_ceiling = c;
514    }
515    if let Some(k) = body.fact_key {
516        req.fact_key = k;
517    }
518    if let Some(s) = body.named_forget_strategies {
519        req.named_forget_strategies = s;
520    }
521
522    let report = mnemo_compliance::trajectory::trajectory_audit(&events, &req)
523        .map_err(|e| AppError(CoreError::Validation(e.to_string())))?;
524
525    let response = serde_json::json!({
526        "report": report,
527        "all_ok": report.all_ok(),
528    });
529
530    Ok(Json(response))
531}
532
533/// POST /v1/delegate -- delegate permissions to another agent.
534///
535/// The caller must provide their `agent_id` and must have `Delegate`
536/// permission on the target memories. Without a full auth middleware
537/// this is advisory; production deployments should add an auth layer.
538pub async fn delegate_handler(
539    State(engine): State<AppState>,
540    Json(body): Json<DelegateRequest>,
541) -> Result<Json<serde_json::Value>, AppError> {
542    let permission: Permission = body
543        .permission
544        .parse()
545        .map_err(|e: CoreError| AppError(e))?;
546
547    let caller_agent_id = body
548        .agent_id
549        .unwrap_or_else(|| engine.default_agent_id.clone());
550
551    let scope = if let Some(ref ids) = body.memory_ids {
552        let parsed: std::result::Result<Vec<Uuid>, _> =
553            ids.iter().map(|s| Uuid::parse_str(s)).collect();
554        match parsed {
555            Ok(uuids) => {
556                // Verify caller has Delegate permission on each memory
557                for mid in &uuids {
558                    let has_perm = engine
559                        .storage
560                        .check_permission(*mid, &caller_agent_id, Permission::Delegate)
561                        .await?;
562                    if !has_perm {
563                        return Err(AppError(CoreError::PermissionDenied(format!(
564                            "agent '{}' lacks delegate permission on memory {}",
565                            caller_agent_id, mid
566                        ))));
567                    }
568                }
569                DelegationScope::ByMemoryId(uuids)
570            }
571            Err(e) => {
572                return Err(AppError(CoreError::Validation(format!(
573                    "invalid UUID in memory_ids: {e}"
574                ))));
575            }
576        }
577    } else if let Some(ref tags) = body.tags {
578        DelegationScope::ByTag(tags.clone())
579    } else {
580        DelegationScope::AllMemories
581    };
582
583    let now = chrono::Utc::now();
584    let expires_at = body
585        .expires_in_hours
586        .map(|h| (now + chrono::Duration::seconds((h * 3600.0) as i64)).to_rfc3339());
587
588    let delegation = Delegation {
589        id: Uuid::now_v7(),
590        delegator_id: caller_agent_id,
591        delegate_id: body.delegate_id.clone(),
592        permission,
593        scope,
594        max_depth: body.max_depth.unwrap_or(0),
595        current_depth: 0,
596        parent_delegation_id: None,
597        created_at: now.to_rfc3339(),
598        expires_at,
599        revoked_at: None,
600    };
601
602    engine.storage.insert_delegation(&delegation).await?;
603
604    let response = serde_json::json!({
605        "delegation_id": delegation.id.to_string(),
606        "delegator": delegation.delegator_id,
607        "delegate": delegation.delegate_id,
608        "permission": delegation.permission.to_string(),
609        "status": "delegated",
610    });
611
612    Ok(Json(response))
613}
614
615/// GET /v1/health
616pub async fn health_handler() -> Json<serde_json::Value> {
617    Json(serde_json::json!({"status": "ok"}))
618}
619
620// ---------------------------------------------------------------------------
621// GenAI semantic convention helpers
622// ---------------------------------------------------------------------------
623
624struct GenAiFields {
625    event_type: EventType,
626    model: Option<String>,
627    tokens_input: Option<i64>,
628    tokens_output: Option<i64>,
629    cost_usd: Option<f64>,
630}
631
632/// Extract GenAI semantic convention fields from OTLP span attributes.
633/// See: <https://opentelemetry.io/docs/specs/semconv/gen-ai/>
634fn extract_genai_fields(span: &serde_json::Value) -> GenAiFields {
635    let attributes = span.get("attributes").and_then(|v| v.as_array());
636
637    let mut model = None;
638    let mut tokens_input = None;
639    let mut tokens_output = None;
640    let mut cost_usd = None;
641    let mut operation_name = None;
642
643    if let Some(attrs) = attributes {
644        for attr in attrs {
645            let key = match attr.get("key").and_then(|k| k.as_str()) {
646                Some(k) => k,
647                None => continue,
648            };
649            let value = attr.get("value");
650
651            match key {
652                "gen_ai.request.model" => {
653                    model = value
654                        .and_then(|v| v.get("stringValue"))
655                        .and_then(|v| v.as_str())
656                        .map(|s| s.to_string());
657                }
658                "gen_ai.usage.input_tokens" => {
659                    tokens_input = value.and_then(|v| v.get("intValue")).and_then(|v| {
660                        v.as_str()
661                            .and_then(|s| s.parse::<i64>().ok())
662                            .or_else(|| v.as_i64())
663                    });
664                }
665                "gen_ai.usage.output_tokens" => {
666                    tokens_output = value.and_then(|v| v.get("intValue")).and_then(|v| {
667                        v.as_str()
668                            .and_then(|s| s.parse::<i64>().ok())
669                            .or_else(|| v.as_i64())
670                    });
671                }
672                "gen_ai.usage.cost" => {
673                    cost_usd = value
674                        .and_then(|v| v.get("doubleValue"))
675                        .and_then(|v| v.as_f64());
676                }
677                "gen_ai.operation.name" => {
678                    operation_name = value
679                        .and_then(|v| v.get("stringValue"))
680                        .and_then(|v| v.as_str())
681                        .map(|s| s.to_string());
682                }
683                _ => {}
684            }
685        }
686    }
687
688    // If no operation_name from attributes, fall back to span name.
689    let op = operation_name.or_else(|| {
690        span.get("name")
691            .and_then(|v| v.as_str())
692            .map(|s| s.to_string())
693    });
694
695    // Map operation name to EventType.
696    let event_type = match op.as_deref() {
697        Some(s) if s.contains("chat") => EventType::AssistantMessage,
698        Some(s) if s.contains("embed") => EventType::RetrievalQuery,
699        Some(s) if s.contains("tool") => EventType::ToolCall,
700        _ => EventType::ToolCall, // default
701    };
702
703    GenAiFields {
704        event_type,
705        model,
706        tokens_input,
707        tokens_output,
708        cost_usd,
709    }
710}
711
712/// POST /v1/ingest/otlp -- ingest simplified OTLP JSON spans as agent events.
713pub async fn otlp_ingest_handler(
714    State(engine): State<AppState>,
715    Json(body): Json<serde_json::Value>,
716) -> Result<Json<serde_json::Value>, AppError> {
717    let resource_spans = body
718        .get("resourceSpans")
719        .and_then(|v| v.as_array())
720        .cloned()
721        .unwrap_or_default();
722
723    let mut count: u64 = 0;
724
725    for rs in &resource_spans {
726        // Extract agent_id from resource attributes (service.name or agent.id).
727        let resource_agent_id = rs
728            .get("resource")
729            .and_then(|r| r.get("attributes"))
730            .and_then(|attrs| attrs.as_array())
731            .and_then(|attrs| {
732                attrs.iter().find_map(|attr| {
733                    let key = attr.get("key")?.as_str()?;
734                    if key == "agent.id" || key == "service.name" {
735                        attr.get("value")
736                            .and_then(|v| v.get("stringValue"))
737                            .and_then(|v| v.as_str())
738                            .map(|s| s.to_string())
739                    } else {
740                        None
741                    }
742                })
743            });
744
745        let scope_spans = rs
746            .get("scopeSpans")
747            .and_then(|v| v.as_array())
748            .cloned()
749            .unwrap_or_default();
750
751        for ss in &scope_spans {
752            let spans = ss
753                .get("spans")
754                .and_then(|v| v.as_array())
755                .cloned()
756                .unwrap_or_default();
757
758            for span in &spans {
759                let trace_id = span
760                    .get("traceId")
761                    .and_then(|v| v.as_str())
762                    .map(|s| s.to_string());
763
764                let span_id = span
765                    .get("spanId")
766                    .and_then(|v| v.as_str())
767                    .map(|s| s.to_string());
768
769                let agent_id = resource_agent_id
770                    .clone()
771                    .unwrap_or_else(|| engine.default_agent_id.clone());
772
773                // Compute latency from start/end nanosecond timestamps.
774                // OTLP encodes nanos as either JSON strings or integers.
775                let start_nano: u64 = span
776                    .get("startTimeUnixNano")
777                    .and_then(|v| {
778                        v.as_str()
779                            .and_then(|s| s.parse::<u64>().ok())
780                            .or_else(|| v.as_u64())
781                    })
782                    .unwrap_or(0);
783
784                let end_nano: u64 = span
785                    .get("endTimeUnixNano")
786                    .and_then(|v| {
787                        v.as_str()
788                            .and_then(|s| s.parse::<u64>().ok())
789                            .or_else(|| v.as_u64())
790                    })
791                    .unwrap_or(0);
792
793                let latency_ms = if end_nano > start_nano {
794                    Some(((end_nano - start_nano) / 1_000_000) as i64)
795                } else {
796                    None
797                };
798
799                // Convert startTimeUnixNano to RFC3339 timestamp.
800                let timestamp = if start_nano > 0 {
801                    let secs = (start_nano / 1_000_000_000) as i64;
802                    let nsecs = (start_nano % 1_000_000_000) as u32;
803                    chrono::DateTime::from_timestamp(secs, nsecs)
804                        .map(|dt| dt.to_rfc3339())
805                        .unwrap_or_else(|| chrono::Utc::now().to_rfc3339())
806                } else {
807                    chrono::Utc::now().to_rfc3339()
808                };
809
810                // Collect span attributes as the event payload.
811                let payload = span
812                    .get("attributes")
813                    .cloned()
814                    .unwrap_or(serde_json::json!({}));
815
816                let genai = extract_genai_fields(span);
817
818                let content_hash =
819                    compute_content_hash(&payload.to_string(), &agent_id, &timestamp);
820
821                let event = AgentEvent {
822                    id: Uuid::now_v7(),
823                    agent_id,
824                    thread_id: None,
825                    run_id: None,
826                    parent_event_id: None,
827                    event_type: genai.event_type,
828                    payload,
829                    trace_id,
830                    span_id,
831                    model: genai.model,
832                    tokens_input: genai.tokens_input,
833                    tokens_output: genai.tokens_output,
834                    latency_ms,
835                    cost_usd: genai.cost_usd,
836                    timestamp,
837                    logical_clock: 0,
838                    content_hash,
839                    prev_hash: None,
840                    embedding: None,
841                };
842
843                engine.storage.insert_event(&event).await?;
844                count += 1;
845            }
846        }
847    }
848
849    Ok(Json(serde_json::json!({"accepted": count})))
850}
851
852// ---------------------------------------------------------------------------
853// Tests
854// ---------------------------------------------------------------------------
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859
860    #[test]
861    fn test_extract_genai_fields_chat_span() {
862        let span = serde_json::json!({
863            "name": "chat gpt-4",
864            "attributes": [
865                {"key": "gen_ai.request.model", "value": {"stringValue": "gpt-4"}},
866                {"key": "gen_ai.usage.input_tokens", "value": {"intValue": "150"}},
867                {"key": "gen_ai.usage.output_tokens", "value": {"intValue": "50"}},
868                {"key": "gen_ai.usage.cost", "value": {"doubleValue": 0.006}},
869                {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}}
870            ]
871        });
872        let fields = extract_genai_fields(&span);
873        assert_eq!(fields.event_type, EventType::AssistantMessage);
874        assert_eq!(fields.model.as_deref(), Some("gpt-4"));
875        assert_eq!(fields.tokens_input, Some(150));
876        assert_eq!(fields.tokens_output, Some(50));
877        assert!((fields.cost_usd.unwrap() - 0.006).abs() < 1e-9);
878    }
879
880    #[test]
881    fn test_extract_genai_fields_non_genai_default() {
882        let span = serde_json::json!({
883            "name": "http.request",
884            "attributes": [
885                {"key": "http.method", "value": {"stringValue": "GET"}}
886            ]
887        });
888        let fields = extract_genai_fields(&span);
889        assert_eq!(fields.event_type, EventType::ToolCall);
890        assert!(fields.model.is_none());
891        assert!(fields.tokens_input.is_none());
892    }
893}