use std::collections::HashMap;
use ai_agents_core::{ChatMessage, Role};
use ai_agents_hitl::{ApprovalRequest, ApprovalResolvedOutcome, ApprovalResult, ApprovalTrigger};
use ai_agents_observability::manager::ObservabilityCursor;
use ai_agents_observability::{ObservabilityReport, ObservationEvent};
use ai_agents_runtime::RuntimeAgent;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::fixtures::RecordingToolLog;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolExecutionSource {
Llm,
Skill,
Plan,
StateAction,
OnEnter,
OnExit,
PostTransition,
Spawner,
Orchestration,
Mock,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolExecutionRecord {
pub call_id: String,
pub tool_id: String,
pub requested_name: String,
pub source: ToolExecutionSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor_id: Option<String>,
pub arguments_original: Value,
pub arguments_executed: Value,
#[serde(default = "default_executed_true")]
pub executed: bool,
pub success: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
pub started_at: DateTime<Utc>,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observability_span_id: Option<String>,
}
fn default_executed_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillEvidence {
pub selected_skill_id: Option<String>,
pub executed_skill_id: Option<String>,
pub no_match: bool,
pub clarification_requested: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DisambiguationStatus {
Clear,
Skipped,
Triggered,
Clarified,
BestGuess,
Abandoned,
GiveUp,
Escalated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisambiguationEvidence {
pub status: DisambiguationStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ambiguity_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FactsEvidence {
pub actor_id: Option<String>,
pub facts: Vec<Value>,
pub before_count: Option<usize>,
pub after_count: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelationshipEvidence {
pub actor_id: Option<String>,
pub model: Option<String>,
pub available_perspectives: Vec<String>,
pub current: Option<Value>,
pub before: Option<Value>,
pub after: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersonaEvidence {
pub secret_revealed: bool,
pub revealed_secret_ids: Vec<String>,
pub revealed_secret_count: usize,
pub evolution_events: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnObservabilityEvidence {
pub trace_id: Option<String>,
pub span_ids: Vec<String>,
pub report: Option<ObservabilityReport>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalDecision {
Approved,
Rejected,
Modified,
Timeout,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ApprovalTriggerEvidence {
Tool { name: String },
Condition { name: String, matched: String },
State { from: Option<String>, to: String },
}
impl From<&ApprovalTrigger> for ApprovalTriggerEvidence {
fn from(trigger: &ApprovalTrigger) -> Self {
match trigger {
ApprovalTrigger::Tool { name, .. } => Self::Tool { name: name.clone() },
ApprovalTrigger::Condition { name, matched } => Self::Condition {
name: name.clone(),
matched: matched.clone(),
},
ApprovalTrigger::State { from, to } => Self::State {
from: from.clone(),
to: to.clone(),
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalEvidence {
pub request_id: String,
pub trigger: ApprovalTriggerEvidence,
pub raw_decision: ApprovalDecision,
pub effective_decision: ApprovalDecision,
#[serde(default, skip_serializing)]
pub original_args: Option<Value>,
#[serde(default, skip_serializing)]
pub modified_args: Option<Value>,
#[serde(default, skip_serializing)]
pub effective_args: Option<Value>,
#[serde(default, skip_serializing)]
pub message: String,
#[serde(default, skip_serializing)]
pub rejection_reason: Option<String>,
#[serde(default, skip_serializing)]
pub error: Option<String>,
}
impl ApprovalEvidence {
pub fn from_resolution(
request: &ApprovalRequest,
raw_result: &ApprovalResult,
effective_result: &ApprovalResolvedOutcome,
) -> Self {
let original_args = match &request.trigger {
ApprovalTrigger::Tool { args, .. } => Some(args.clone()),
_ => None,
};
let (effective_decision, changes, rejection_reason, error) = match effective_result {
ApprovalResolvedOutcome::Approved => (ApprovalDecision::Approved, None, None, None),
ApprovalResolvedOutcome::Rejected { reason } => {
(ApprovalDecision::Rejected, None, reason.clone(), None)
}
ApprovalResolvedOutcome::Modified { changes } => {
(ApprovalDecision::Modified, Some(changes), None, None)
}
ApprovalResolvedOutcome::Error { message } => {
(ApprovalDecision::Error, None, None, Some(message.clone()))
}
};
let modified_args = changes.and_then(|changes| {
original_args
.as_ref()
.map(|original| apply_argument_changes(original, changes))
});
let effective_args = match effective_decision {
ApprovalDecision::Approved => original_args.clone(),
ApprovalDecision::Modified => modified_args.clone(),
ApprovalDecision::Rejected | ApprovalDecision::Timeout | ApprovalDecision::Error => {
None
}
};
Self {
request_id: request.id.clone(),
trigger: ApprovalTriggerEvidence::from(&request.trigger),
raw_decision: approval_result_decision(raw_result),
effective_decision,
original_args,
modified_args,
effective_args,
message: request.message.clone(),
rejection_reason,
error,
}
}
}
fn approval_result_decision(result: &ApprovalResult) -> ApprovalDecision {
match result {
ApprovalResult::Approved => ApprovalDecision::Approved,
ApprovalResult::Rejected { .. } => ApprovalDecision::Rejected,
ApprovalResult::Modified { .. } => ApprovalDecision::Modified,
ApprovalResult::Timeout => ApprovalDecision::Timeout,
}
}
fn apply_argument_changes(original: &Value, changes: &HashMap<String, Value>) -> Value {
let mut modified = original.clone();
if let Value::Object(arguments) = &mut modified {
for (key, value) in changes {
arguments.insert(key.clone(), value.clone());
}
modified
} else {
Value::Object(changes.clone().into_iter().collect())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmMessageEvidence {
pub role: Role,
#[serde(default, skip_serializing)]
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmRequestEvidence {
pub messages: Vec<LlmMessageEvidence>,
}
impl LlmRequestEvidence {
pub fn from_messages(messages: &[ChatMessage]) -> Self {
Self {
messages: messages
.iter()
.map(|message| LlmMessageEvidence {
role: message.role,
content: message.content.clone(),
})
.collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnEvidence {
pub response_metadata: Option<Value>,
pub state: Option<String>,
pub state_history: Vec<ai_agents_core::StateTransitionEvent>,
pub context: Value,
pub tool_executions: Vec<ToolExecutionRecord>,
#[serde(default)]
pub approvals: Vec<ApprovalEvidence>,
#[serde(default, skip_serializing)]
pub llm_requests: Vec<LlmRequestEvidence>,
pub skill: Option<SkillEvidence>,
pub disambiguation: Option<DisambiguationEvidence>,
pub facts: Option<FactsEvidence>,
pub relationship: Option<RelationshipEvidence>,
pub persona: Option<PersonaEvidence>,
pub orchestration: Option<Value>,
pub observability: Option<TurnObservabilityEvidence>,
}
pub fn collect_turn_evidence(
agent: &RuntimeAgent,
response_metadata: Option<HashMap<String, Value>>,
tool_log: &RecordingToolLog,
tool_start_index: usize,
before_relationship: Option<Value>,
) -> TurnEvidence {
let observability = agent.observability().map(|manager| {
let report = manager.generate_report();
let events = manager.raw_events();
observability_evidence(events, report)
});
collect_turn_evidence_with_observability(
agent,
response_metadata,
tool_log,
tool_start_index,
before_relationship,
observability,
)
}
pub(crate) fn collect_turn_evidence_since(
agent: &RuntimeAgent,
response_metadata: Option<HashMap<String, Value>>,
tool_log: &RecordingToolLog,
tool_start_index: usize,
before_relationship: Option<Value>,
observability_cursor: Option<ObservabilityCursor>,
) -> TurnEvidence {
let observability = agent
.observability()
.zip(observability_cursor)
.map(|(manager, cursor)| {
let snapshot = manager.snapshot_since(cursor);
observability_evidence(snapshot.events, snapshot.report)
});
collect_turn_evidence_with_observability(
agent,
response_metadata,
tool_log,
tool_start_index,
before_relationship,
observability,
)
}
fn collect_turn_evidence_with_observability(
agent: &RuntimeAgent,
response_metadata: Option<HashMap<String, Value>>,
tool_log: &RecordingToolLog,
tool_start_index: usize,
before_relationship: Option<Value>,
observability: Option<TurnObservabilityEvidence>,
) -> TurnEvidence {
let context_map = agent.get_context();
let context = serde_json::to_value(&context_map).unwrap_or(Value::Null);
let metadata_value = response_metadata
.clone()
.and_then(|metadata| serde_json::to_value(metadata).ok());
let orchestration = metadata_value
.as_ref()
.and_then(|metadata| metadata.get("orchestration").cloned())
.or_else(|| context.get("orchestration").cloned());
let disambiguation = infer_disambiguation(metadata_value.as_ref(), &context);
let skill = infer_skill(metadata_value.as_ref(), disambiguation.as_ref());
let actor_id = agent.actor_id();
let facts = Some(FactsEvidence {
actor_id: actor_id.clone(),
facts: agent
.actor_facts()
.into_iter()
.filter_map(|fact| serde_json::to_value(fact).ok())
.collect(),
before_count: None,
after_count: Some(agent.actor_facts().len()),
});
let relationship = collect_relationship(agent, actor_id.clone(), before_relationship);
let persona = collect_persona(agent, &context_map);
TurnEvidence {
response_metadata: metadata_value,
state: agent.current_state(),
state_history: agent.state_history(),
context,
tool_executions: tool_log.records_since(tool_start_index),
approvals: Vec::new(),
llm_requests: Vec::new(),
skill,
disambiguation,
facts,
relationship,
persona,
orchestration,
observability,
}
}
fn observability_evidence(
events: Vec<ObservationEvent>,
report: ObservabilityReport,
) -> TurnObservabilityEvidence {
TurnObservabilityEvidence {
trace_id: events.last().map(|event| event.trace_id.clone()),
span_ids: events.iter().map(|event| event.span_id.clone()).collect(),
report: Some(report),
}
}
pub fn relationship_snapshot(agent: &RuntimeAgent) -> Option<Value> {
let actor_id = agent.actor_id()?;
let manager = agent.relationship_manager()?;
manager.relationship_as_value(&actor_id).ok().flatten()
}
fn infer_disambiguation(
metadata: Option<&Value>,
context: &Value,
) -> Option<DisambiguationEvidence> {
if let Some(disambiguation) = metadata.and_then(|m| m.get("disambiguation")) {
let status = match disambiguation
.get("status")
.and_then(Value::as_str)
.unwrap_or("triggered")
{
"awaiting_clarification" | "awaiting_confirmation" => DisambiguationStatus::Triggered,
"clarified" => DisambiguationStatus::Clarified,
"best_guess" => DisambiguationStatus::BestGuess,
"abandoned" => DisambiguationStatus::Abandoned,
"give_up" => DisambiguationStatus::GiveUp,
"escalated" => DisambiguationStatus::Escalated,
"skipped" => DisambiguationStatus::Skipped,
"clear" => DisambiguationStatus::Clear,
_ => DisambiguationStatus::Triggered,
};
let detection = disambiguation.get("detection");
return Some(DisambiguationEvidence {
status,
ambiguity_type: detection.and_then(|d| d.get("type")).map(|v| v.to_string()),
confidence: detection
.and_then(|d| d.get("confidence"))
.and_then(Value::as_f64)
.map(|v| v as f32),
resolved: disambiguation.get("resolved").cloned(),
});
}
if context
.pointer("/disambiguation/resolved")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Some(DisambiguationEvidence {
status: DisambiguationStatus::Clarified,
ambiguity_type: None,
confidence: None,
resolved: context.get("disambiguation").cloned(),
});
}
None
}
fn infer_skill(
metadata: Option<&Value>,
disambiguation: Option<&DisambiguationEvidence>,
) -> Option<SkillEvidence> {
let skill_id = metadata
.and_then(|m| m.get("skill_id"))
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
metadata
.and_then(|m| m.get("disambiguation"))
.and_then(|d| d.get("skill_id"))
.and_then(Value::as_str)
.map(str::to_string)
});
if skill_id.is_none() && disambiguation.is_none() {
return None;
}
Some(SkillEvidence {
selected_skill_id: skill_id.clone(),
executed_skill_id: skill_id,
no_match: false,
clarification_requested: disambiguation
.map(|d| d.status == DisambiguationStatus::Triggered)
.unwrap_or(false),
})
}
fn collect_relationship(
agent: &RuntimeAgent,
actor_id: Option<String>,
before: Option<Value>,
) -> Option<RelationshipEvidence> {
let actor_id = actor_id?;
let manager = agent.relationship_manager()?;
let current = manager.relationship_as_value(&actor_id).ok().flatten();
let model = current
.as_ref()
.and_then(|value| value.get("model"))
.and_then(Value::as_str)
.map(str::to_string);
let mut available = vec!["agent_to_actor".to_string(), "mutual".to_string()];
if model.as_deref() == Some("two_sided") {
available.push("perceived_actor_to_agent".to_string());
}
Some(RelationshipEvidence {
actor_id: Some(actor_id),
model,
available_perspectives: available,
before,
after: current.clone(),
current,
})
}
fn collect_persona(
agent: &RuntimeAgent,
context_map: &HashMap<String, Value>,
) -> Option<PersonaEvidence> {
let manager = agent.persona_manager()?;
let revealed_count = manager.revealed_secrets(context_map).len();
Some(PersonaEvidence {
secret_revealed: revealed_count > 0,
revealed_secret_ids: Vec::new(),
revealed_secret_count: revealed_count,
evolution_events: manager.history().len(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
type CollectTurnEvidenceFn = fn(
&RuntimeAgent,
Option<HashMap<String, Value>>,
&RecordingToolLog,
usize,
Option<Value>,
) -> TurnEvidence;
#[test]
fn collect_turn_evidence_keeps_five_argument_signature() {
let _: CollectTurnEvidenceFn = collect_turn_evidence;
}
#[test]
fn llm_request_preserves_roles_and_keeps_content_in_memory() {
let evidence = LlmRequestEvidence::from_messages(&[
ChatMessage::system("persona and reasoning prompt"),
ChatMessage::user("private user history"),
ChatMessage::assistant("private assistant history"),
]);
assert_eq!(evidence.messages.len(), 3);
assert_eq!(evidence.messages[0].role, Role::System);
assert_eq!(evidence.messages[1].content, "private user history");
let serialized = serde_json::to_string(&evidence).unwrap();
assert!(!serialized.contains("persona and reasoning prompt"));
assert!(!serialized.contains("private user history"));
assert!(!serialized.contains("private assistant history"));
}
#[test]
fn normalizes_awaiting_confirmation_as_triggered_disambiguation() {
let metadata = json!({
"disambiguation": {
"status": "awaiting_confirmation",
"detection": {
"type": "missing_target",
"confidence": 0.25
}
}
});
let evidence = infer_disambiguation(Some(&metadata), &Value::Null).unwrap();
assert_eq!(evidence.status, DisambiguationStatus::Triggered);
assert_eq!(evidence.confidence, Some(0.25));
}
#[test]
fn normalizes_modified_approval_and_keeps_sensitive_values_in_memory() {
let request = ApprovalRequest::new(
ApprovalTrigger::tool("transfer", json!({"amount": 100, "currency": "USD"})),
"Approve this transfer?",
);
let mut changes = HashMap::new();
changes.insert("amount".to_string(), json!(25));
let evidence = ApprovalEvidence::from_resolution(
&request,
&ApprovalResult::Modified {
changes: changes.clone(),
},
&ApprovalResolvedOutcome::Modified { changes },
);
assert_eq!(evidence.raw_decision, ApprovalDecision::Modified);
assert_eq!(evidence.effective_decision, ApprovalDecision::Modified);
assert_eq!(
evidence.original_args,
Some(json!({"amount": 100, "currency": "USD"}))
);
assert_eq!(
evidence.modified_args,
Some(json!({"amount": 25, "currency": "USD"}))
);
assert_eq!(evidence.effective_args, evidence.modified_args);
assert_eq!(evidence.message, "Approve this transfer?");
let serialized = serde_json::to_value(&evidence).unwrap();
assert!(serialized.get("original_args").is_none());
assert!(serialized.get("modified_args").is_none());
assert!(serialized.get("effective_args").is_none());
assert!(serialized.get("message").is_none());
}
#[test]
fn normalizes_timeout_to_effective_error_without_executable_arguments() {
let request = ApprovalRequest::new(
ApprovalTrigger::tool("delete", json!({"path": "/private"})),
"Delete file?",
);
let evidence = ApprovalEvidence::from_resolution(
&request,
&ApprovalResult::Timeout,
&ApprovalResolvedOutcome::Error {
message: "approval timed out".to_string(),
},
);
assert_eq!(evidence.raw_decision, ApprovalDecision::Timeout);
assert_eq!(evidence.effective_decision, ApprovalDecision::Error);
assert_eq!(evidence.error.as_deref(), Some("approval timed out"));
assert!(evidence.effective_args.is_none());
}
}