Skip to main content

atomr_agents_tool/
evidence.rs

1//! Required evidence-trace on tool returns (FR-22).
2//!
3//! Autonomous trading requires every order to be explainable to its
4//! evidence. An [`EvidenceTrace`] ties a tool's decision to the inputs
5//! that justified it ([`EvidenceRef`]s — doc hashes, checkpoint pointers,
6//! measure ids) plus a rationale. The trace travels in the
7//! [`ToolReturn::ContentAndArtifact`] artifact channel under a reserved
8//! key, so it auto-persists into the recording layer's `ToolCallRecord`
9//! and surfaces via telemetry — no enum change needed.
10//!
11//! [`ExplainedTool`] is reusable middleware: with
12//! [`ExplainabilityPolicy::Require`], a money-moving tool whose return
13//! lacks a trace is rejected with a typed [`MissingEvidence`].
14
15use 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
23/// Reserved artifact key under which the evidence trace is stored.
24pub const EVIDENCE_KEY: &str = "__evidence_trace__";
25
26/// A reference to a piece of evidence behind a decision.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum EvidenceRef {
30    /// Hash of a sourced document (resolvable in the retrieval store).
31    DocHash(String),
32    /// Pointer to a checkpoint `(workflow:run:step)`.
33    CheckpointRef(String),
34    /// A computed measure / metric id.
35    MeasureId(String),
36}
37
38/// The evidence + rationale behind a tool's decision.
39#[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    /// Attach this trace to a [`ToolReturn`], moving plain `Content` into
54    /// a `ContentAndArtifact` whose artifact carries the trace. `Command`
55    /// returns are passed through unchanged (no content to annotate).
56    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    /// Extract a trace from a [`ToolReturn`], if present.
85    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/// Per-tool/desk explainability requirement.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum ExplainabilityPolicy {
99    /// Reject a return lacking an evidence trace.
100    Require,
101    /// Allow, but the absence is observable to the caller.
102    Warn,
103    /// No requirement.
104    Off,
105}
106
107/// Raised when [`ExplainabilityPolicy::Require`] is violated.
108#[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    /// Enforce the policy against a return. Returns `Ok(was_present)` for
122    /// `Warn`/`Off`; `Err(MissingEvidence)` for `Require` with no trace.
123    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
132/// Middleware enforcing an [`ExplainabilityPolicy`] on any [`RichTool`].
133/// Wraps without touching the inner tool's constructor (cf. `WalledTool`).
134pub 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        // content preserved
220        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}