Skip to main content

uaicp_adapter_rig/
lib.rs

1//! # uaicp-adapter-rig
2//!
3//! UAICP v0.3 Reliability Protocol Adapter for Rig
4//! (Rust-native AI Agent Framework)
5//!
6//! Provides strict UAICP envelope mapping for Rig multi-agent conversations,
7//! swarm handoff tracking, evidence extraction, gate enforcement, and rollback support.
8
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use serde_json::{Map, Value};
12use std::collections::HashMap;
13use uaicp_core::{
14    now_iso, AgentIdentity, EvidenceObject, EvidenceType, MessageEnvelope, PolicyResult,
15    RollbackActionType, UaicpAdapter, UaicpRollbackAction, UaicpState,
16    UaicpStreaming,
17};
18use uuid::Uuid;
19
20fn map_to_hashmap(map: Map<String, Value>) -> HashMap<String, Value> {
21    map.into_iter().collect()
22}
23
24// ============================================================
25// Rig Framework Native Types
26// ============================================================
27
28/// Represents native Rig conversation state that the adapter consumes.
29/// Rig uses a message-based conversation model with agent contexts.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct RigState {
32    pub messages: Vec<RigMessage>,
33    #[serde(default)]
34    pub parent_trace_id: Option<String>,
35    #[serde(default)]
36    pub streaming_chunk: Option<serde_json::Value>,
37    #[serde(default)]
38    pub metadata: HashMap<String, serde_json::Value>,
39}
40
41/// Rig message types - corresponds to Rig's message enum variants.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(tag = "role", content = "content")]
44pub enum RigMessage {
45    #[serde(rename = "user")]
46    User(String),
47    #[serde(rename = "assistant")]
48    Assistant {
49        content: Option<String>,
50        tool_calls: Option<Vec<RigToolCall>>,
51    },
52    #[serde(rename = "tool")]
53    Tool {
54        tool_call_id: String,
55        content: String,
56        name: Option<String>,
57    },
58    #[serde(rename = "system")]
59    System(String),
60    #[serde(rename = "handoff")]
61    Handoff {
62        target_agent: String,
63        content: Option<String>,
64        #[serde(default)]
65        metadata: HashMap<String, serde_json::Value>,
66    },
67}
68
69/// Rig tool call representation.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct RigToolCall {
72    pub id: String,
73    pub name: String,
74    pub arguments: HashMap<String, serde_json::Value>,
75}
76
77/// Rig streaming event from agent responses.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct RigStreamingEvent {
80    pub chunk_id: String,
81    pub content: String,
82    pub is_final: bool,
83}
84
85// ============================================================
86// Rig Adapter Implementation
87// ============================================================
88
89#[derive(Debug, Clone)]
90pub struct RigAdapter {
91    agent_id: String,
92    version: String,
93}
94
95impl RigAdapter {
96    pub fn new(agent_id: String, version: &str) -> Self {
97        Self {
98            agent_id,
99            version: version.to_string(),
100        }
101    }
102
103    fn extract_parent_trace(&self, state: &RigState) -> Option<String> {
104        // 1. Direct state attribute
105        if state.parent_trace_id.is_some() {
106            return state.parent_trace_id.clone();
107        }
108
109        // 2. Scan messages for swarm handoff metadata
110        for msg in &state.messages {
111            if let RigMessage::Handoff { metadata, .. } = msg {
112                if let Some(parent) = metadata.get("uaicp_parent_trace_id") {
113                    if let Some(s) = parent.as_str() {
114                        return Some(s.to_string());
115                    }
116                }
117            }
118            // Check assistant message metadata
119            if let RigMessage::Assistant { tool_calls, .. } = msg {
120                if let Some(calls) = tool_calls {
121                    for tc in calls {
122                        if let Some(args) = tc.arguments.get("uaicp_parent_trace_id") {
123                            if let Some(s) = args.as_str() {
124                                return Some(s.to_string());
125                            }
126                        }
127                    }
128                }
129            }
130        }
131
132        // 3. State-level metadata
133        if let Some(parent) = state.metadata.get("parent_trace_id") {
134            if let Some(s) = parent.as_str() {
135                return Some(s.to_string());
136            }
137        }
138
139        None
140    }
141
142    fn extract_evidence(&self, messages: &[RigMessage]) -> Vec<EvidenceObject> {
143        let mut evidence = Vec::new();
144
145        for msg in messages {
146            match msg {
147                RigMessage::Tool {
148                    tool_call_id,
149                    content,
150                    name,
151                } => {
152                    evidence.push(EvidenceObject {
153                        evidence_id: tool_call_id.clone(),
154                        evidence_type: EvidenceType::ToolOutput,
155                        source: name.clone().unwrap_or_else(|| "unknown_tool".to_string()),
156                        collected_at: now_iso(),
157                        hash: None,
158                        parent_trace_id: None,
159                        payload: map_to_hashmap(serde_json::json!({
160                            "tool_call_id": tool_call_id,
161                            "output": content,
162                        }).as_object().cloned().unwrap_or_default()),
163                    });
164                }
165                RigMessage::Assistant {
166                    content: _,
167                    tool_calls,
168                } => {
169                    if let Some(calls) = tool_calls {
170                        for tc in calls {
171                            evidence.push(EvidenceObject {
172                                evidence_id: tc.id.clone(),
173                                evidence_type: EvidenceType::ExternalApi,
174                                source: tc.name.clone(),
175                                collected_at: now_iso(),
176                                hash: None,
177                                parent_trace_id: None,
178                            payload: map_to_hashmap(serde_json::json!({
179                                "intent": "TOOL_INVOCATION",
180                                "arguments": tc.arguments,
181                            }).as_object().cloned().unwrap_or_default()),
182                            });
183                        }
184                    }
185                }
186                RigMessage::User(content) => {
187                    evidence.push(EvidenceObject {
188                        evidence_id: format!("ev-human-{}", &Uuid::new_v4().to_string()[..8]),
189                        evidence_type: EvidenceType::HumanInput,
190                        source: "user".to_string(),
191                        collected_at: now_iso(),
192                        hash: None,
193                        parent_trace_id: None,
194                        payload: map_to_hashmap(serde_json::json!({ "content": content })
195                            .as_object()
196                            .cloned()
197                            .unwrap_or_default()),
198                    });
199                }
200                RigMessage::Handoff {
201                    target_agent,
202                    content,
203                    metadata,
204                } => {
205                    let mut payload_map = serde_json::json!({
206                        "target_agent": target_agent,
207                    })
208                    .as_object()
209                    .cloned()
210                    .unwrap_or_default();
211                    if let Some(c) = content {
212                        payload_map.insert("content".to_string(), serde_json::json!(c));
213                    }
214                    for (k, v) in metadata {
215                        payload_map.insert(k.clone(), v.clone());
216                    }
217                    evidence.push(EvidenceObject {
218                        evidence_id: format!("ev-handoff-{}", &Uuid::new_v4().to_string()[..8]),
219                        evidence_type: EvidenceType::ExternalApi,
220                        source: "rig_handoff".to_string(),
221                        collected_at: now_iso(),
222                        hash: None,
223                        parent_trace_id: metadata
224                            .get("uaicp_parent_trace_id")
225                            .and_then(|v| v.as_str())
226                            .map(|s| s.to_string()),
227                        payload: map_to_hashmap(payload_map),
228                    });
229                }
230                RigMessage::System(_) => {}
231            }
232        }
233
234        evidence
235    }
236}
237
238#[async_trait]
239impl UaicpAdapter for RigAdapter {
240    type FrameworkState = RigState;
241    type FrameworkToolCall = RigToolCall;
242
243    fn map_to_envelope(&self, state: &Self::FrameworkState) -> MessageEnvelope {
244        let parent_trace = self.extract_parent_trace(state);
245        let evidence = self.extract_evidence(&state.messages);
246
247        let temp_envelope = MessageEnvelope {
248            uaicp_version: "0.3.0".to_string(),
249            trace_id: format!("rig-trace-{}", Uuid::new_v4()),
250            parent_trace_id: parent_trace.clone(),
251            state: UaicpState::Executing,
252            identity: AgentIdentity {
253                agent_id: self.agent_id.clone(),
254                agent_type: "rig".to_string(),
255                framework: "rig-rs".to_string(),
256                version: self.version.clone(),
257            },
258            evidence: evidence.clone(),
259            outcome: None,
260            streaming: state.streaming_chunk.as_ref().and_then(|v| self.stream_partial(v)),
261            rollback_action: self.rollback_payload(&MessageEnvelope {
262                uaicp_version: "0.3.0".to_string(),
263                trace_id: "temp".to_string(),
264                parent_trace_id: parent_trace,
265                state: UaicpState::Executing,
266                identity: AgentIdentity {
267                    agent_id: self.agent_id.clone(),
268                    agent_type: "rig".to_string(),
269                    framework: "rig-rs".to_string(),
270                    version: self.version.clone(),
271                },
272                evidence,
273                outcome: None,
274                streaming: None,
275                rollback_action: None,
276            }),
277        };
278
279        temp_envelope
280    }
281
282    fn normalize_evidence(&self, tool_call: &Self::FrameworkToolCall) -> EvidenceObject {
283        EvidenceObject {
284            evidence_id: tool_call.id.clone(),
285            evidence_type: EvidenceType::ToolOutput,
286            source: tool_call.name.clone(),
287            collected_at: now_iso(),
288            hash: None,
289            parent_trace_id: None,
290            payload: tool_call.arguments.clone(),
291        }
292    }
293
294    async fn verify_gates(&self, envelope: &MessageEnvelope) -> bool {
295        // Gate 1: Deterministic verification — reject if no evidence exists
296        !envelope.evidence.is_empty()
297    }
298
299    async fn enforce_policy(&self, envelope: &MessageEnvelope) -> PolicyResult {
300        // Gate 2: Policy enforcement — block high-risk writes without rollback
301        if envelope.rollback_action.is_none() {
302            PolicyResult {
303                allowed: false,
304                reason: "[UAICP Policy Deny] High-risk operation with no rollback_action defined.".to_string(),
305                requires_review: true,
306            }
307        } else {
308            PolicyResult {
309                allowed: true,
310                reason: "Policy cleared".to_string(),
311                requires_review: false,
312            }
313        }
314    }
315
316    fn stream_partial(&self, chunk: &serde_json::Value) -> Option<UaicpStreaming> {
317        if chunk.is_null() {
318            return None;
319        }
320        Some(UaicpStreaming {
321            chunk_id: chunk
322                .get("chunk_id")
323                .and_then(|v| v.as_str())
324                .map(|s| s.to_string())
325                .unwrap_or_else(|| format!("chunk-{}", &Uuid::new_v4().to_string()[..8])),
326            is_final: chunk
327                .get("is_final")
328                .and_then(|v| v.as_bool())
329                .unwrap_or(false),
330            content: chunk
331                .get("content")
332                .and_then(|v| v.as_str())
333                .unwrap_or("")
334                .to_string(),
335        })
336    }
337
338    fn rollback_payload(&self, envelope: &MessageEnvelope) -> Option<UaicpRollbackAction> {
339        // Default rollback for Rig: compensating tool call strategy
340        // Check if there are any tool calls that could be compensated
341        let has_tool_evidence = envelope
342            .evidence
343            .iter()
344            .any(|e| e.evidence_type == EvidenceType::ToolOutput);
345
346        if has_tool_evidence {
347            let tool_ids: Vec<String> = envelope
348                .evidence
349                .iter()
350                .filter(|e| e.evidence_type == EvidenceType::ToolOutput)
351                .map(|e| e.evidence_id.clone())
352                .collect();
353
354            Some(UaicpRollbackAction {
355                action_type: RollbackActionType::CompensatingToolCall,
356                payload: map_to_hashmap(serde_json::json!({
357                    "compensate_tools": tool_ids,
358                    "strategy": "reverse_tool_invocation_order"
359                })
360                .as_object()
361                .cloned()
362                .unwrap_or_default()),
363            })
364        } else {
365            Some(UaicpRollbackAction {
366                action_type: RollbackActionType::ManualIntervention,
367                payload: map_to_hashmap(serde_json::json!({
368                    "note": "Operator manually resolves Rig swarm sub-task"
369                })
370                .as_object()
371                .cloned()
372                .unwrap_or_default()),
373            })
374        }
375    }
376}
377
378// ============================================================
379// Tests
380// ============================================================
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    fn create_test_state() -> RigState {
387        RigState {
388            messages: vec![
389                RigMessage::User("Calculate 2+2".to_string()),
390                RigMessage::Assistant {
391                    content: None,
392                    tool_calls: Some(vec![RigToolCall {
393                        id: "call-123".to_string(),
394                        name: "calculator".to_string(),
395                        arguments: map_to_hashmap(serde_json::json!({"expr": "2+2"}).as_object().cloned().unwrap_or_default()),
396                    }]),
397                },
398                RigMessage::Tool {
399                    tool_call_id: "call-123".to_string(),
400                    content: "4".to_string(),
401                    name: Some("calculator".to_string()),
402                },
403            ],
404            parent_trace_id: Some("parent-trace-abc".to_string()),
405            streaming_chunk: None,
406            metadata: HashMap::new(),
407        }
408    }
409
410    #[test]
411    fn test_adapter_creation() {
412        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
413        assert_eq!(adapter.agent_id, "test-agent");
414        assert_eq!(adapter.version, "0.3.0");
415    }
416
417    #[test]
418    fn test_map_to_envelope() {
419        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
420        let state = create_test_state();
421
422        let envelope = adapter.map_to_envelope(&state);
423
424        assert_eq!(envelope.uaicp_version, "0.3.0");
425        assert!(envelope.trace_id.starts_with("rig-trace-"));
426        assert_eq!(envelope.parent_trace_id, Some("parent-trace-abc".to_string()));
427        assert_eq!(envelope.state, UaicpState::Executing);
428        assert_eq!(envelope.identity.agent_type, "rig");
429        assert_eq!(envelope.identity.framework, "rig-rs");
430        assert!(!envelope.evidence.is_empty());
431    }
432
433    #[test]
434    fn test_extract_parent_trace_from_state() {
435        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
436        let state = create_test_state();
437
438        let parent = adapter.extract_parent_trace(&state);
439        assert_eq!(parent, Some("parent-trace-abc".to_string()));
440    }
441
442    #[test]
443    fn test_extract_parent_trace_from_handoff_metadata() {
444        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
445        let mut metadata = HashMap::new();
446        metadata.insert(
447            "uaicp_parent_trace_id".to_string(),
448            serde_json::json!("handoff-trace-xyz"),
449        );
450
451        let state = RigState {
452            messages: vec![RigMessage::Handoff {
453                target_agent: "agent-b".to_string(),
454                content: Some("Handing off".to_string()),
455                metadata,
456            }],
457            parent_trace_id: None,
458            streaming_chunk: None,
459            metadata: HashMap::new(),
460        };
461
462        let parent = adapter.extract_parent_trace(&state);
463        assert_eq!(parent, Some("handoff-trace-xyz".to_string()));
464    }
465
466    #[test]
467    fn test_extract_evidence_from_messages() {
468        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
469        let state = create_test_state();
470
471        let evidence = adapter.extract_evidence(&state.messages);
472
473        // Should have: user message, tool call (intent), tool result
474        assert!(evidence.len() >= 3);
475
476        let human_input = evidence
477            .iter()
478            .find(|e| e.evidence_type == EvidenceType::HumanInput);
479        assert!(human_input.is_some());
480
481        let tool_output = evidence
482            .iter()
483            .find(|e| e.evidence_type == EvidenceType::ToolOutput);
484        assert!(tool_output.is_some());
485
486        let external_api = evidence
487            .iter()
488            .find(|e| e.evidence_type == EvidenceType::ExternalApi);
489        assert!(external_api.is_some());
490    }
491
492    #[test]
493    fn test_normalize_evidence() {
494        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
495        let tool_call = RigToolCall {
496            id: "call-456".to_string(),
497            name: "search".to_string(),
498            arguments: map_to_hashmap(serde_json::json!({"query": "test"}).as_object().cloned().unwrap_or_default()),
499        };
500
501        let evidence = adapter.normalize_evidence(&tool_call);
502
503        assert_eq!(evidence.evidence_id, "call-456");
504        assert_eq!(evidence.evidence_type, EvidenceType::ToolOutput);
505        assert_eq!(evidence.source, "search");
506        assert!(evidence.payload.contains_key("query"));
507    }
508
509    #[tokio::test]
510    async fn test_verify_gates_with_evidence() {
511        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
512        let state = create_test_state();
513        let envelope = adapter.map_to_envelope(&state);
514
515        let result = adapter.verify_gates(&envelope).await;
516        assert!(result);
517    }
518
519    #[tokio::test]
520    async fn test_verify_gates_without_evidence() {
521        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
522        let state = RigState {
523            messages: vec![],
524            parent_trace_id: None,
525            streaming_chunk: None,
526            metadata: HashMap::new(),
527        };
528        let envelope = adapter.map_to_envelope(&state);
529
530        let result = adapter.verify_gates(&envelope).await;
531        assert!(!result);
532    }
533
534    #[tokio::test]
535    async fn test_enforce_policy_with_rollback() {
536        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
537        let state = create_test_state();
538        let envelope = adapter.map_to_envelope(&state);
539
540        let result = adapter.enforce_policy(&envelope).await;
541        assert!(result.allowed);
542        assert!(!result.requires_review);
543    }
544
545    #[tokio::test]
546    async fn test_enforce_policy_without_rollback() {
547        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
548        // Create envelope without rollback
549        let envelope = MessageEnvelope {
550            uaicp_version: "0.3.0".to_string(),
551            trace_id: "test-trace".to_string(),
552            parent_trace_id: None,
553            state: UaicpState::Executing,
554            identity: AgentIdentity {
555                agent_id: "test".to_string(),
556                agent_type: "rig".to_string(),
557                framework: "rig-rs".to_string(),
558                version: "0.3.0".to_string(),
559            },
560            evidence: vec![],
561            outcome: None,
562            streaming: None,
563            rollback_action: None,
564        };
565
566        let result = adapter.enforce_policy(&envelope).await;
567        assert!(!result.allowed);
568        assert!(result.requires_review);
569    }
570
571    #[test]
572    fn test_stream_partial_with_chunk() {
573        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
574        let chunk = serde_json::json!({
575            "chunk_id": "chunk-1",
576            "content": "Hello",
577            "is_final": false
578        });
579
580        let streaming = adapter.stream_partial(&chunk);
581
582        assert!(streaming.is_some());
583        let s = streaming.unwrap();
584        assert_eq!(s.chunk_id, "chunk-1");
585        assert_eq!(s.content, "Hello");
586        assert!(!s.is_final);
587    }
588
589    #[test]
590    fn test_stream_partial_with_null() {
591        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
592        let chunk = serde_json::Value::Null;
593
594        let streaming = adapter.stream_partial(&chunk);
595        assert!(streaming.is_none());
596    }
597
598    #[test]
599    fn test_rollback_payload_with_tools() {
600        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
601        let state = create_test_state();
602        let envelope = adapter.map_to_envelope(&state);
603
604        let rollback = adapter.rollback_payload(&envelope);
605
606        assert!(rollback.is_some());
607        let rb = rollback.unwrap();
608        assert_eq!(rb.action_type, RollbackActionType::CompensatingToolCall);
609        assert!(rb.payload.contains_key("compensate_tools"));
610    }
611
612    #[test]
613    fn test_rollback_payload_without_tools() {
614        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
615        let envelope = MessageEnvelope {
616            uaicp_version: "0.3.0".to_string(),
617            trace_id: "test".to_string(),
618            parent_trace_id: None,
619            state: UaicpState::Executing,
620            identity: AgentIdentity {
621                agent_id: "test".to_string(),
622                agent_type: "rig".to_string(),
623                framework: "rig-rs".to_string(),
624                version: "0.3.0".to_string(),
625            },
626            evidence: vec![],
627            outcome: None,
628            streaming: None,
629            rollback_action: None,
630        };
631
632        let rollback = adapter.rollback_payload(&envelope);
633
634        assert!(rollback.is_some());
635        let rb = rollback.unwrap();
636        assert_eq!(rb.action_type, RollbackActionType::ManualIntervention);
637    }
638
639    #[test]
640    fn test_envelope_state_inference() {
641        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
642        
643        // Test with different message compositions
644        let planning_state = RigState {
645            messages: vec![RigMessage::User("What should I do?".to_string())],
646            parent_trace_id: None,
647            streaming_chunk: None,
648            metadata: HashMap::new(),
649        };
650        let envelope = adapter.map_to_envelope(&planning_state);
651        
652        // Rig adapter defaults to Executing state (can be enhanced with smarter inference)
653        assert_eq!(envelope.state, UaicpState::Executing);
654    }
655
656    #[test]
657    fn test_swarm_handoff_tracking() {
658        let adapter = RigAdapter::new("orchestrator".to_string(), "0.3.0");
659        
660        // Parent orchestrator creates child with trace ID
661        let child_state = RigState {
662            messages: vec![RigMessage::Handoff {
663                target_agent: "worker-1".to_string(),
664                content: Some("Process this task".to_string()),
665                metadata: HashMap::new(),
666            }],
667            parent_trace_id: Some("orchestrator-trace-001".to_string()),
668            streaming_chunk: None,
669            metadata: HashMap::new(),
670        };
671
672        let envelope = adapter.map_to_envelope(&child_state);
673        
674        assert_eq!(envelope.parent_trace_id, Some("orchestrator-trace-001".to_string()));
675        
676        // Check that handoff itself is recorded as evidence
677        let handoff_evidence = envelope.evidence.iter()
678            .find(|e| e.source == "rig_handoff");
679        assert!(handoff_evidence.is_some());
680    }
681
682    #[test]
683    fn test_streaming_chunk_extraction() {
684        let adapter = RigAdapter::new("test-agent".to_string(), "0.3.0");
685        
686        let state = RigState {
687            messages: vec![],
688            parent_trace_id: None,
689            streaming_chunk: Some(serde_json::json!({
690                "chunk_id": "stream-1",
691                "content": "Thinking...",
692                "is_final": false
693            })),
694            metadata: HashMap::new(),
695        };
696
697        let envelope = adapter.map_to_envelope(&state);
698        
699        assert!(envelope.streaming.is_some());
700        let streaming = envelope.streaming.unwrap();
701        assert_eq!(streaming.content, "Thinking...");
702        assert!(!streaming.is_final);
703    }
704}