1use 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#[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#[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#[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#[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#[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 if state.parent_trace_id.is_some() {
106 return state.parent_trace_id.clone();
107 }
108
109 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 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 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 !envelope.evidence.is_empty()
297 }
298
299 async fn enforce_policy(&self, envelope: &MessageEnvelope) -> PolicyResult {
300 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 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#[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 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 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 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 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 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 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}