1use std::collections::HashMap;
2
3use ai_agents_core::{ChatMessage, Role};
4use ai_agents_hitl::{ApprovalRequest, ApprovalResolvedOutcome, ApprovalResult, ApprovalTrigger};
5use ai_agents_observability::manager::ObservabilityCursor;
6use ai_agents_observability::{ObservabilityReport, ObservationEvent};
7use ai_agents_runtime::RuntimeAgent;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::fixtures::RecordingToolLog;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum ToolExecutionSource {
18 Llm,
19 Skill,
20 Plan,
22 StateAction,
23 OnEnter,
24 OnExit,
25 PostTransition,
26 Spawner,
27 Orchestration,
28 Mock,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ToolExecutionRecord {
34 pub call_id: String,
36 pub tool_id: String,
38 pub requested_name: String,
40 pub source: ToolExecutionSource,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub state: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub actor_id: Option<String>,
48 pub arguments_original: Value,
50 pub arguments_executed: Value,
52 #[serde(default = "default_executed_true")]
54 pub executed: bool,
55 pub success: bool,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub output: Option<Value>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub error: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub metadata: Option<Value>,
66 pub started_at: DateTime<Utc>,
68 pub duration_ms: u64,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub observability_span_id: Option<String>,
73}
74
75fn default_executed_true() -> bool {
76 true
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct SkillEvidence {
82 pub selected_skill_id: Option<String>,
84 pub executed_skill_id: Option<String>,
86 pub no_match: bool,
88 pub clarification_requested: bool,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(rename_all = "snake_case")]
95pub enum DisambiguationStatus {
96 Clear,
97 Skipped,
98 Triggered,
99 Clarified,
100 BestGuess,
101 Abandoned,
102 GiveUp,
103 Escalated,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct DisambiguationEvidence {
109 pub status: DisambiguationStatus,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub ambiguity_type: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub confidence: Option<f32>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub resolved: Option<Value>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct FactsEvidence {
125 pub actor_id: Option<String>,
127 pub facts: Vec<Value>,
129 pub before_count: Option<usize>,
131 pub after_count: Option<usize>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct RelationshipEvidence {
138 pub actor_id: Option<String>,
140 pub model: Option<String>,
142 pub available_perspectives: Vec<String>,
144 pub current: Option<Value>,
146 pub before: Option<Value>,
148 pub after: Option<Value>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct PersonaEvidence {
155 pub secret_revealed: bool,
157 pub revealed_secret_ids: Vec<String>,
159 pub revealed_secret_count: usize,
161 pub evolution_events: usize,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct TurnObservabilityEvidence {
168 pub trace_id: Option<String>,
170 pub span_ids: Vec<String>,
172 pub report: Option<ObservabilityReport>,
174}
175
176#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
178#[serde(rename_all = "snake_case")]
179pub enum ApprovalDecision {
180 Approved,
181 Rejected,
182 Modified,
183 Timeout,
184 Error,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
189#[serde(tag = "type", rename_all = "snake_case")]
190pub enum ApprovalTriggerEvidence {
191 Tool { name: String },
192 Condition { name: String, matched: String },
193 State { from: Option<String>, to: String },
194}
195
196impl From<&ApprovalTrigger> for ApprovalTriggerEvidence {
197 fn from(trigger: &ApprovalTrigger) -> Self {
198 match trigger {
199 ApprovalTrigger::Tool { name, .. } => Self::Tool { name: name.clone() },
200 ApprovalTrigger::Condition { name, matched } => Self::Condition {
201 name: name.clone(),
202 matched: matched.clone(),
203 },
204 ApprovalTrigger::State { from, to } => Self::State {
205 from: from.clone(),
206 to: to.clone(),
207 },
208 }
209 }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct ApprovalEvidence {
215 pub request_id: String,
217 pub trigger: ApprovalTriggerEvidence,
219 pub raw_decision: ApprovalDecision,
221 pub effective_decision: ApprovalDecision,
223 #[serde(default, skip_serializing)]
225 pub original_args: Option<Value>,
226 #[serde(default, skip_serializing)]
228 pub modified_args: Option<Value>,
229 #[serde(default, skip_serializing)]
231 pub effective_args: Option<Value>,
232 #[serde(default, skip_serializing)]
234 pub message: String,
235 #[serde(default, skip_serializing)]
237 pub rejection_reason: Option<String>,
238 #[serde(default, skip_serializing)]
240 pub error: Option<String>,
241}
242
243impl ApprovalEvidence {
244 pub fn from_resolution(
246 request: &ApprovalRequest,
247 raw_result: &ApprovalResult,
248 effective_result: &ApprovalResolvedOutcome,
249 ) -> Self {
250 let original_args = match &request.trigger {
251 ApprovalTrigger::Tool { args, .. } => Some(args.clone()),
252 _ => None,
253 };
254 let (effective_decision, changes, rejection_reason, error) = match effective_result {
255 ApprovalResolvedOutcome::Approved => (ApprovalDecision::Approved, None, None, None),
256 ApprovalResolvedOutcome::Rejected { reason } => {
257 (ApprovalDecision::Rejected, None, reason.clone(), None)
258 }
259 ApprovalResolvedOutcome::Modified { changes } => {
260 (ApprovalDecision::Modified, Some(changes), None, None)
261 }
262 ApprovalResolvedOutcome::Error { message } => {
263 (ApprovalDecision::Error, None, None, Some(message.clone()))
264 }
265 };
266 let modified_args = changes.and_then(|changes| {
267 original_args
268 .as_ref()
269 .map(|original| apply_argument_changes(original, changes))
270 });
271 let effective_args = match effective_decision {
272 ApprovalDecision::Approved => original_args.clone(),
273 ApprovalDecision::Modified => modified_args.clone(),
274 ApprovalDecision::Rejected | ApprovalDecision::Timeout | ApprovalDecision::Error => {
275 None
276 }
277 };
278
279 Self {
280 request_id: request.id.clone(),
281 trigger: ApprovalTriggerEvidence::from(&request.trigger),
282 raw_decision: approval_result_decision(raw_result),
283 effective_decision,
284 original_args,
285 modified_args,
286 effective_args,
287 message: request.message.clone(),
288 rejection_reason,
289 error,
290 }
291 }
292}
293
294fn approval_result_decision(result: &ApprovalResult) -> ApprovalDecision {
295 match result {
296 ApprovalResult::Approved => ApprovalDecision::Approved,
297 ApprovalResult::Rejected { .. } => ApprovalDecision::Rejected,
298 ApprovalResult::Modified { .. } => ApprovalDecision::Modified,
299 ApprovalResult::Timeout => ApprovalDecision::Timeout,
300 }
301}
302
303fn apply_argument_changes(original: &Value, changes: &HashMap<String, Value>) -> Value {
304 let mut modified = original.clone();
305 if let Value::Object(arguments) = &mut modified {
306 for (key, value) in changes {
307 arguments.insert(key.clone(), value.clone());
308 }
309 modified
310 } else {
311 Value::Object(changes.clone().into_iter().collect())
312 }
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct LlmMessageEvidence {
318 pub role: Role,
320 #[serde(default, skip_serializing)]
322 pub content: String,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct LlmRequestEvidence {
328 pub messages: Vec<LlmMessageEvidence>,
330}
331
332impl LlmRequestEvidence {
333 pub fn from_messages(messages: &[ChatMessage]) -> Self {
335 Self {
336 messages: messages
337 .iter()
338 .map(|message| LlmMessageEvidence {
339 role: message.role,
340 content: message.content.clone(),
341 })
342 .collect(),
343 }
344 }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct TurnEvidence {
350 pub response_metadata: Option<Value>,
352 pub state: Option<String>,
354 pub state_history: Vec<ai_agents_core::StateTransitionEvent>,
356 pub context: Value,
358 pub tool_executions: Vec<ToolExecutionRecord>,
360 #[serde(default)]
362 pub approvals: Vec<ApprovalEvidence>,
363 #[serde(default, skip_serializing)]
365 pub llm_requests: Vec<LlmRequestEvidence>,
366 pub skill: Option<SkillEvidence>,
368 pub disambiguation: Option<DisambiguationEvidence>,
370 pub facts: Option<FactsEvidence>,
372 pub relationship: Option<RelationshipEvidence>,
374 pub persona: Option<PersonaEvidence>,
376 pub orchestration: Option<Value>,
378 pub observability: Option<TurnObservabilityEvidence>,
380}
381
382pub fn collect_turn_evidence(
384 agent: &RuntimeAgent,
385 response_metadata: Option<HashMap<String, Value>>,
386 tool_log: &RecordingToolLog,
387 tool_start_index: usize,
388 before_relationship: Option<Value>,
389) -> TurnEvidence {
390 let observability = agent.observability().map(|manager| {
391 let report = manager.generate_report();
392 let events = manager.raw_events();
393 observability_evidence(events, report)
394 });
395 collect_turn_evidence_with_observability(
396 agent,
397 response_metadata,
398 tool_log,
399 tool_start_index,
400 before_relationship,
401 observability,
402 )
403}
404
405pub(crate) fn collect_turn_evidence_since(
407 agent: &RuntimeAgent,
408 response_metadata: Option<HashMap<String, Value>>,
409 tool_log: &RecordingToolLog,
410 tool_start_index: usize,
411 before_relationship: Option<Value>,
412 observability_cursor: Option<ObservabilityCursor>,
413) -> TurnEvidence {
414 let observability = agent
415 .observability()
416 .zip(observability_cursor)
417 .map(|(manager, cursor)| {
418 let snapshot = manager.snapshot_since(cursor);
419 observability_evidence(snapshot.events, snapshot.report)
420 });
421 collect_turn_evidence_with_observability(
422 agent,
423 response_metadata,
424 tool_log,
425 tool_start_index,
426 before_relationship,
427 observability,
428 )
429}
430
431fn collect_turn_evidence_with_observability(
433 agent: &RuntimeAgent,
434 response_metadata: Option<HashMap<String, Value>>,
435 tool_log: &RecordingToolLog,
436 tool_start_index: usize,
437 before_relationship: Option<Value>,
438 observability: Option<TurnObservabilityEvidence>,
439) -> TurnEvidence {
440 let context_map = agent.get_context();
441 let context = serde_json::to_value(&context_map).unwrap_or(Value::Null);
442 let metadata_value = response_metadata
443 .clone()
444 .and_then(|metadata| serde_json::to_value(metadata).ok());
445 let orchestration = metadata_value
446 .as_ref()
447 .and_then(|metadata| metadata.get("orchestration").cloned())
448 .or_else(|| context.get("orchestration").cloned());
449 let disambiguation = infer_disambiguation(metadata_value.as_ref(), &context);
450 let skill = infer_skill(metadata_value.as_ref(), disambiguation.as_ref());
451 let actor_id = agent.actor_id();
452 let facts = Some(FactsEvidence {
453 actor_id: actor_id.clone(),
454 facts: agent
455 .actor_facts()
456 .into_iter()
457 .filter_map(|fact| serde_json::to_value(fact).ok())
458 .collect(),
459 before_count: None,
460 after_count: Some(agent.actor_facts().len()),
461 });
462 let relationship = collect_relationship(agent, actor_id.clone(), before_relationship);
463 let persona = collect_persona(agent, &context_map);
464
465 TurnEvidence {
466 response_metadata: metadata_value,
467 state: agent.current_state(),
468 state_history: agent.state_history(),
469 context,
470 tool_executions: tool_log.records_since(tool_start_index),
471 approvals: Vec::new(),
472 llm_requests: Vec::new(),
473 skill,
474 disambiguation,
475 facts,
476 relationship,
477 persona,
478 orchestration,
479 observability,
480 }
481}
482
483fn observability_evidence(
485 events: Vec<ObservationEvent>,
486 report: ObservabilityReport,
487) -> TurnObservabilityEvidence {
488 TurnObservabilityEvidence {
489 trace_id: events.last().map(|event| event.trace_id.clone()),
490 span_ids: events.iter().map(|event| event.span_id.clone()).collect(),
491 report: Some(report),
492 }
493}
494
495pub fn relationship_snapshot(agent: &RuntimeAgent) -> Option<Value> {
496 let actor_id = agent.actor_id()?;
497 let manager = agent.relationship_manager()?;
498 manager.relationship_as_value(&actor_id).ok().flatten()
499}
500
501fn infer_disambiguation(
502 metadata: Option<&Value>,
503 context: &Value,
504) -> Option<DisambiguationEvidence> {
505 if let Some(disambiguation) = metadata.and_then(|m| m.get("disambiguation")) {
506 let status = match disambiguation
507 .get("status")
508 .and_then(Value::as_str)
509 .unwrap_or("triggered")
510 {
511 "awaiting_clarification" | "awaiting_confirmation" => DisambiguationStatus::Triggered,
512 "clarified" => DisambiguationStatus::Clarified,
513 "best_guess" => DisambiguationStatus::BestGuess,
514 "abandoned" => DisambiguationStatus::Abandoned,
515 "give_up" => DisambiguationStatus::GiveUp,
516 "escalated" => DisambiguationStatus::Escalated,
517 "skipped" => DisambiguationStatus::Skipped,
518 "clear" => DisambiguationStatus::Clear,
519 _ => DisambiguationStatus::Triggered,
520 };
521 let detection = disambiguation.get("detection");
522 return Some(DisambiguationEvidence {
523 status,
524 ambiguity_type: detection.and_then(|d| d.get("type")).map(|v| v.to_string()),
525 confidence: detection
526 .and_then(|d| d.get("confidence"))
527 .and_then(Value::as_f64)
528 .map(|v| v as f32),
529 resolved: disambiguation.get("resolved").cloned(),
530 });
531 }
532
533 if context
534 .pointer("/disambiguation/resolved")
535 .and_then(Value::as_bool)
536 .unwrap_or(false)
537 {
538 return Some(DisambiguationEvidence {
539 status: DisambiguationStatus::Clarified,
540 ambiguity_type: None,
541 confidence: None,
542 resolved: context.get("disambiguation").cloned(),
543 });
544 }
545
546 None
547}
548
549fn infer_skill(
550 metadata: Option<&Value>,
551 disambiguation: Option<&DisambiguationEvidence>,
552) -> Option<SkillEvidence> {
553 let skill_id = metadata
554 .and_then(|m| m.get("skill_id"))
555 .and_then(Value::as_str)
556 .map(str::to_string)
557 .or_else(|| {
558 metadata
559 .and_then(|m| m.get("disambiguation"))
560 .and_then(|d| d.get("skill_id"))
561 .and_then(Value::as_str)
562 .map(str::to_string)
563 });
564
565 if skill_id.is_none() && disambiguation.is_none() {
566 return None;
567 }
568
569 Some(SkillEvidence {
570 selected_skill_id: skill_id.clone(),
571 executed_skill_id: skill_id,
572 no_match: false,
573 clarification_requested: disambiguation
574 .map(|d| d.status == DisambiguationStatus::Triggered)
575 .unwrap_or(false),
576 })
577}
578
579fn collect_relationship(
580 agent: &RuntimeAgent,
581 actor_id: Option<String>,
582 before: Option<Value>,
583) -> Option<RelationshipEvidence> {
584 let actor_id = actor_id?;
585 let manager = agent.relationship_manager()?;
586 let current = manager.relationship_as_value(&actor_id).ok().flatten();
587 let model = current
588 .as_ref()
589 .and_then(|value| value.get("model"))
590 .and_then(Value::as_str)
591 .map(str::to_string);
592 let mut available = vec!["agent_to_actor".to_string(), "mutual".to_string()];
593 if model.as_deref() == Some("two_sided") {
594 available.push("perceived_actor_to_agent".to_string());
595 }
596 Some(RelationshipEvidence {
597 actor_id: Some(actor_id),
598 model,
599 available_perspectives: available,
600 before,
601 after: current.clone(),
602 current,
603 })
604}
605
606fn collect_persona(
607 agent: &RuntimeAgent,
608 context_map: &HashMap<String, Value>,
609) -> Option<PersonaEvidence> {
610 let manager = agent.persona_manager()?;
611 let revealed_count = manager.revealed_secrets(context_map).len();
612 Some(PersonaEvidence {
613 secret_revealed: revealed_count > 0,
614 revealed_secret_ids: Vec::new(),
615 revealed_secret_count: revealed_count,
616 evolution_events: manager.history().len(),
617 })
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623 use serde_json::json;
624
625 type CollectTurnEvidenceFn = fn(
627 &RuntimeAgent,
628 Option<HashMap<String, Value>>,
629 &RecordingToolLog,
630 usize,
631 Option<Value>,
632 ) -> TurnEvidence;
633
634 #[test]
635 fn collect_turn_evidence_keeps_five_argument_signature() {
636 let _: CollectTurnEvidenceFn = collect_turn_evidence;
637 }
638
639 #[test]
640 fn llm_request_preserves_roles_and_keeps_content_in_memory() {
641 let evidence = LlmRequestEvidence::from_messages(&[
642 ChatMessage::system("persona and reasoning prompt"),
643 ChatMessage::user("private user history"),
644 ChatMessage::assistant("private assistant history"),
645 ]);
646
647 assert_eq!(evidence.messages.len(), 3);
648 assert_eq!(evidence.messages[0].role, Role::System);
649 assert_eq!(evidence.messages[1].content, "private user history");
650
651 let serialized = serde_json::to_string(&evidence).unwrap();
652 assert!(!serialized.contains("persona and reasoning prompt"));
653 assert!(!serialized.contains("private user history"));
654 assert!(!serialized.contains("private assistant history"));
655 }
656
657 #[test]
658 fn normalizes_awaiting_confirmation_as_triggered_disambiguation() {
659 let metadata = json!({
660 "disambiguation": {
661 "status": "awaiting_confirmation",
662 "detection": {
663 "type": "missing_target",
664 "confidence": 0.25
665 }
666 }
667 });
668
669 let evidence = infer_disambiguation(Some(&metadata), &Value::Null).unwrap();
670 assert_eq!(evidence.status, DisambiguationStatus::Triggered);
671 assert_eq!(evidence.confidence, Some(0.25));
672 }
673
674 #[test]
675 fn normalizes_modified_approval_and_keeps_sensitive_values_in_memory() {
676 let request = ApprovalRequest::new(
677 ApprovalTrigger::tool("transfer", json!({"amount": 100, "currency": "USD"})),
678 "Approve this transfer?",
679 );
680 let mut changes = HashMap::new();
681 changes.insert("amount".to_string(), json!(25));
682 let evidence = ApprovalEvidence::from_resolution(
683 &request,
684 &ApprovalResult::Modified {
685 changes: changes.clone(),
686 },
687 &ApprovalResolvedOutcome::Modified { changes },
688 );
689
690 assert_eq!(evidence.raw_decision, ApprovalDecision::Modified);
691 assert_eq!(evidence.effective_decision, ApprovalDecision::Modified);
692 assert_eq!(
693 evidence.original_args,
694 Some(json!({"amount": 100, "currency": "USD"}))
695 );
696 assert_eq!(
697 evidence.modified_args,
698 Some(json!({"amount": 25, "currency": "USD"}))
699 );
700 assert_eq!(evidence.effective_args, evidence.modified_args);
701 assert_eq!(evidence.message, "Approve this transfer?");
702
703 let serialized = serde_json::to_value(&evidence).unwrap();
704 assert!(serialized.get("original_args").is_none());
705 assert!(serialized.get("modified_args").is_none());
706 assert!(serialized.get("effective_args").is_none());
707 assert!(serialized.get("message").is_none());
708 }
709
710 #[test]
711 fn normalizes_timeout_to_effective_error_without_executable_arguments() {
712 let request = ApprovalRequest::new(
713 ApprovalTrigger::tool("delete", json!({"path": "/private"})),
714 "Delete file?",
715 );
716 let evidence = ApprovalEvidence::from_resolution(
717 &request,
718 &ApprovalResult::Timeout,
719 &ApprovalResolvedOutcome::Error {
720 message: "approval timed out".to_string(),
721 },
722 );
723
724 assert_eq!(evidence.raw_decision, ApprovalDecision::Timeout);
725 assert_eq!(evidence.effective_decision, ApprovalDecision::Error);
726 assert_eq!(evidence.error.as_deref(), Some("approval timed out"));
727 assert!(evidence.effective_args.is_none());
728 }
729}