1use async_trait::async_trait;
16use atomr_agents_core::{AgentError, InvokeCtx, Result, Value};
17use serde::{Deserialize, Serialize};
18use thiserror::Error;
19
20use crate::descriptor::ToolDescriptor;
21use crate::tool_return::{RichTool, ToolReturn};
22
23pub const EVIDENCE_KEY: &str = "__evidence_trace__";
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum EvidenceRef {
30 DocHash(String),
32 CheckpointRef(String),
34 MeasureId(String),
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct EvidenceTrace {
41 pub inputs: Vec<EvidenceRef>,
42 pub rationale: String,
43}
44
45impl EvidenceTrace {
46 pub fn new(rationale: impl Into<String>, inputs: Vec<EvidenceRef>) -> Self {
47 Self {
48 inputs,
49 rationale: rationale.into(),
50 }
51 }
52
53 pub fn attach(self, ret: ToolReturn) -> ToolReturn {
57 let trace_val = serde_json::to_value(&self).unwrap_or(Value::Null);
58 match ret {
59 ToolReturn::Content(content) => ToolReturn::ContentAndArtifact {
60 content,
61 artifact: serde_json::json!({ EVIDENCE_KEY: trace_val }),
62 },
63 ToolReturn::ContentAndArtifact { content, artifact } => {
64 let mut obj = match artifact {
65 Value::Object(m) => m,
66 other => {
67 let mut m = serde_json::Map::new();
68 if !other.is_null() {
69 m.insert("artifact".into(), other);
70 }
71 m
72 }
73 };
74 obj.insert(EVIDENCE_KEY.into(), trace_val);
75 ToolReturn::ContentAndArtifact {
76 content,
77 artifact: Value::Object(obj),
78 }
79 }
80 cmd @ ToolReturn::Command(_) => cmd,
81 }
82 }
83
84 pub fn extract(ret: &ToolReturn) -> Option<EvidenceTrace> {
86 if let ToolReturn::ContentAndArtifact { artifact, .. } = ret {
87 artifact
88 .get(EVIDENCE_KEY)
89 .and_then(|v| serde_json::from_value::<EvidenceTrace>(v.clone()).ok())
90 } else {
91 None
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum ExplainabilityPolicy {
99 Require,
101 Warn,
103 Off,
105}
106
107#[derive(Debug, Error)]
109#[error("missing evidence trace for tool '{tool}' (explainability=Require)")]
110pub struct MissingEvidence {
111 pub tool: String,
112}
113
114impl From<MissingEvidence> for AgentError {
115 fn from(e: MissingEvidence) -> Self {
116 AgentError::PolicyDenied(e.to_string())
117 }
118}
119
120impl ExplainabilityPolicy {
121 pub fn enforce(&self, tool: &str, ret: &ToolReturn) -> Result<bool, MissingEvidence> {
124 let present = EvidenceTrace::extract(ret).is_some();
125 match self {
126 ExplainabilityPolicy::Require if !present => Err(MissingEvidence { tool: tool.into() }),
127 _ => Ok(present),
128 }
129 }
130}
131
132pub struct ExplainedTool<T: RichTool> {
135 inner: T,
136 policy: ExplainabilityPolicy,
137}
138
139impl<T: RichTool> ExplainedTool<T> {
140 pub fn new(inner: T, policy: ExplainabilityPolicy) -> Self {
141 Self { inner, policy }
142 }
143}
144
145#[async_trait]
146impl<T: RichTool> RichTool for ExplainedTool<T> {
147 fn descriptor(&self) -> &ToolDescriptor {
148 self.inner.descriptor()
149 }
150
151 async fn invoke_rich(&self, args: Value, ctx: &InvokeCtx) -> Result<ToolReturn> {
152 let ret = self.inner.invoke_rich(args, ctx).await?;
153 self.policy.enforce(&self.inner.descriptor().name, &ret)?;
154 Ok(ret)
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::descriptor::{ToolDescriptor, ToolSchema};
162 use atomr_agents_core::{CallCtx, IterationBudget, MoneyBudget, TimeBudget, TokenBudget, ToolId};
163 use std::time::Duration;
164
165 fn ictx() -> InvokeCtx {
166 InvokeCtx {
167 call: CallCtx::new(
168 None,
169 TokenBudget::new(100),
170 TimeBudget::new(Duration::from_secs(1)),
171 MoneyBudget::from_usd(1.0),
172 IterationBudget::new(1),
173 vec![],
174 ),
175 tool_call_id: "t".into(),
176 raw_args: Value::Null,
177 }
178 }
179
180 fn desc(name: &str) -> ToolDescriptor {
181 ToolDescriptor {
182 id: ToolId::from(name),
183 name: name.into(),
184 description: "".into(),
185 schema: ToolSchema::empty_object(),
186 }
187 }
188
189 struct OrderTool {
190 d: ToolDescriptor,
191 attach_evidence: bool,
192 }
193 #[async_trait]
194 impl RichTool for OrderTool {
195 fn descriptor(&self) -> &ToolDescriptor {
196 &self.d
197 }
198 async fn invoke_rich(&self, _args: Value, _ctx: &InvokeCtx) -> Result<ToolReturn> {
199 let base = ToolReturn::Content(serde_json::json!({"order_id": "o1"}));
200 if self.attach_evidence {
201 Ok(EvidenceTrace::new(
202 "carry signal above threshold",
203 vec![EvidenceRef::DocHash("abc123".into())],
204 )
205 .attach(base))
206 } else {
207 Ok(base)
208 }
209 }
210 }
211
212 #[test]
213 fn attach_then_extract_roundtrip() {
214 let ret = EvidenceTrace::new("why", vec![EvidenceRef::MeasureId("m1".into())])
215 .attach(ToolReturn::Content(serde_json::json!({"x": 1})));
216 let got = EvidenceTrace::extract(&ret).unwrap();
217 assert_eq!(got.rationale, "why");
218 assert_eq!(got.inputs, vec![EvidenceRef::MeasureId("m1".into())]);
219 if let ToolReturn::ContentAndArtifact { content, .. } = ret {
221 assert_eq!(content, serde_json::json!({"x": 1}));
222 } else {
223 panic!("expected ContentAndArtifact");
224 }
225 }
226
227 #[tokio::test]
228 async fn require_rejects_money_tool_without_evidence() {
229 let tool = ExplainedTool::new(
230 OrderTool {
231 d: desc("place_order"),
232 attach_evidence: false,
233 },
234 ExplainabilityPolicy::Require,
235 );
236 let err = tool.invoke_rich(Value::Null, &ictx()).await.unwrap_err();
237 assert!(err.to_string().contains("missing evidence"));
238 }
239
240 #[tokio::test]
241 async fn require_allows_when_evidence_present() {
242 let tool = ExplainedTool::new(
243 OrderTool {
244 d: desc("place_order"),
245 attach_evidence: true,
246 },
247 ExplainabilityPolicy::Require,
248 );
249 let ret = tool.invoke_rich(Value::Null, &ictx()).await.unwrap();
250 assert!(EvidenceTrace::extract(&ret).is_some());
251 }
252}