Skip to main content

agent_engine/runtime/trace/
execution.rs

1//! Task 27 — bounded, correlated tool-execution lifecycle metadata.
2//!
3//! This module bridges the execution gate/ledger and the T7 request trace
4//! envelope. It contains no result body or tool input: previews are represented
5//! only by byte counts. All IDs and wire names are validated bounded trace
6//! types, so hostile model-provided identifiers fail closed to omission.
7
8use std::time::Instant;
9
10use super::{
11    ActivationGrantRef, ExecutionCommitStatus, ExecutionEffect, ExecutionPhase, ToolExecutionEvent,
12    TraceContext, TraceId, WireName,
13};
14use crate::tools::activation::ActivationBasis;
15use crate::tools::catalog::{ToolEffect, ToolId};
16
17#[derive(Clone)]
18pub struct ExecutionCorrelation {
19    trace: TraceContext,
20    session_id: TraceId,
21    turn_id: TraceId,
22    request_id: TraceId,
23}
24
25/// How a call's history entry differs from its raw result, for
26/// [`ExecutionCorrelation::record_virtualised`].
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28pub struct HistoryShaping {
29    /// The result was virtualised: history carries its preview (or, when
30    /// also deduped, the stub that replaced an identical preview).
31    pub virtualised: bool,
32    /// History carries a read-dedupe stub; the value is the bytes it kept
33    /// out of history.
34    pub deduped_bytes: Option<usize>,
35}
36
37impl ExecutionCorrelation {
38    pub fn from_request(trace: &TraceContext, request: &super::RequestCorrelation) -> Self {
39        Self {
40            trace: trace.clone(),
41            session_id: request.session_id.clone(),
42            turn_id: request.turn_id.clone(),
43            request_id: request.request_id.clone(),
44        }
45    }
46
47    #[allow(clippy::too_many_arguments)]
48    pub fn record(
49        &self,
50        tool_call_id: &str,
51        stable_tool_id: &ToolId,
52        wire_name: &str,
53        phase: ExecutionPhase,
54        started: Instant,
55        result_bytes: usize,
56        retained_bytes: usize,
57        activation: ActivationBasis,
58        effect: ToolEffect,
59        commit_status: ExecutionCommitStatus,
60        model_order: usize,
61    ) {
62        self.record_virtualised(
63            tool_call_id,
64            stable_tool_id,
65            wire_name,
66            phase,
67            started,
68            result_bytes,
69            retained_bytes,
70            activation,
71            effect,
72            commit_status,
73            model_order,
74            HistoryShaping::default(),
75        );
76    }
77
78    /// [`Self::record`] for a call whose history entry may be shaped: a tool
79    /// output virtualisation preview (then `result_bytes` is the whole
80    /// output and `retained_bytes` the preview, and the event says
81    /// `virtualised`) or a read-dedupe stub (`retained_bytes` is the stub;
82    /// the event says `deduped` with the bytes kept out of history).
83    #[allow(clippy::too_many_arguments)]
84    pub fn record_virtualised(
85        &self,
86        tool_call_id: &str,
87        stable_tool_id: &ToolId,
88        wire_name: &str,
89        phase: ExecutionPhase,
90        started: Instant,
91        result_bytes: usize,
92        retained_bytes: usize,
93        activation: ActivationBasis,
94        effect: ToolEffect,
95        commit_status: ExecutionCommitStatus,
96        model_order: usize,
97        shaping: HistoryShaping,
98    ) {
99        let Ok(tool_call_id) = TraceId::new(tool_call_id) else {
100            return;
101        };
102        let Ok(stable_tool_id) = TraceId::new(stable_tool_id.as_str()) else {
103            return;
104        };
105        let Ok(wire_name) = WireName::new(wire_name) else {
106            return;
107        };
108        let activation = match activation {
109            ActivationBasis::Core => ActivationGrantRef::Core,
110            ActivationBasis::Exact { catalog_generation } => ActivationGrantRef::Exact {
111                catalog_generation: catalog_generation.value(),
112            },
113        };
114        let effect = match effect {
115            ToolEffect::ReadOnly => ExecutionEffect::ReadOnly,
116            ToolEffect::IdempotentWrite => ExecutionEffect::IdempotentWrite,
117            ToolEffect::NonIdempotent => ExecutionEffect::NonIdempotent,
118        };
119        let event = ToolExecutionEvent {
120            session_id: self.session_id.clone(),
121            turn_id: self.turn_id.clone(),
122            request_id: self.request_id.clone(),
123            tool_call_id,
124            stable_tool_id,
125            wire_name,
126            phase,
127            elapsed_ms: started.elapsed().as_millis() as u64,
128            result_bytes: result_bytes as u64,
129            truncated: retained_bytes < result_bytes,
130            preview_bytes: retained_bytes as u64,
131            activation,
132            effect,
133            commit_status,
134            model_order: model_order as u32,
135            virtualised: shaping.virtualised,
136            deduped: shaping.deduped_bytes.is_some(),
137            deduped_bytes: shaping.deduped_bytes.unwrap_or(0) as u64,
138        };
139        self.trace.record_execution_event(event);
140        self.trace.emit_execution_enriched(&self.request_id);
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::runtime::trace::{
148        AttemptClock, CollectingTraceSink, EndpointMeta, RequestStructure, RequestTracer,
149        TransportKind,
150    };
151    use crate::TurnOutcome;
152
153    #[test]
154    fn records_only_bounded_metadata_with_consistent_ids() {
155        let sink = CollectingTraceSink::new();
156        let trace = TraceContext::with_sink(sink);
157        let request = trace.reserve_request_correlation().unwrap();
158        let correlation = ExecutionCorrelation::from_request(&trace, &request);
159        correlation.record(
160            "toolu_1",
161            &ToolId::builtin("bash"),
162            "bash",
163            ExecutionPhase::ResultRecorded,
164            Instant::now(),
165            9000,
166            32,
167            ActivationBasis::Core,
168            ToolEffect::NonIdempotent,
169            ExecutionCommitStatus::ResultRecorded,
170            0,
171        );
172        let events = trace.execution_events(&request.request_id);
173        assert_eq!(events.len(), 1);
174        let event = &events[0];
175        assert_eq!(event.session_id, request.session_id);
176        assert_eq!(event.turn_id, request.turn_id);
177        assert_eq!(event.request_id, request.request_id);
178        assert_eq!(event.preview_bytes, 32);
179        assert_eq!(event.result_bytes, 9000);
180        assert!(event.truncated);
181        let serialized = serde_json::to_string(event).unwrap();
182        assert!(!serialized.contains("raw result"));
183    }
184
185    /// A virtualised result: `result_bytes` is the whole output,
186    /// `preview_bytes` what history carries; the difference is what was
187    /// kept out of history. Legacy events omit the flag on the wire.
188    #[test]
189    fn virtualised_results_are_flagged_and_sized() {
190        let sink = CollectingTraceSink::new();
191        let trace = TraceContext::with_sink(sink);
192        let request = trace.reserve_request_correlation().unwrap();
193        let correlation = ExecutionCorrelation::from_request(&trace, &request);
194        for (id, virtualised) in [("toolu_v", true), ("toolu_l", false)] {
195            let shaping = HistoryShaping {
196                virtualised,
197                deduped_bytes: None,
198            };
199            correlation.record_virtualised(
200                id,
201                &ToolId::builtin("bash"),
202                "bash",
203                ExecutionPhase::ResultRecorded,
204                Instant::now(),
205                48_213,
206                7_400,
207                ActivationBasis::Core,
208                ToolEffect::NonIdempotent,
209                ExecutionCommitStatus::ResultRecorded,
210                0,
211                shaping,
212            );
213        }
214        let events = trace.execution_events(&request.request_id);
215        assert!(events[0].virtualised && !events[1].virtualised);
216        assert_eq!(events[0].result_bytes - events[0].preview_bytes, 48_213 - 7_400);
217        let wire = serde_json::to_value(&events[0]).unwrap();
218        assert_eq!(wire["virtualised"], true);
219        let legacy = serde_json::to_value(&events[1]).unwrap();
220        assert!(legacy.get("virtualised").is_none(), "absent unless set: {legacy}");
221        let back: ToolExecutionEvent = serde_json::from_value(legacy).unwrap();
222        assert!(!back.virtualised, "old records still read");
223    }
224
225    /// A read-dedupe stub: flagged with the bytes it kept out of history;
226    /// events without it serialize exactly as before.
227    #[test]
228    fn deduped_results_are_flagged_with_the_bytes_kept_out() {
229        let sink = CollectingTraceSink::new();
230        let trace = TraceContext::with_sink(sink);
231        let request = trace.reserve_request_correlation().unwrap();
232        let correlation = ExecutionCorrelation::from_request(&trace, &request);
233        for (id, deduped_bytes) in [("toolu_d", Some(7_900)), ("toolu_l", None)] {
234            correlation.record_virtualised(
235                id,
236                &ToolId::builtin("read"),
237                "read",
238                ExecutionPhase::ResultRecorded,
239                Instant::now(),
240                8_040,
241                140,
242                ActivationBasis::Core,
243                ToolEffect::ReadOnly,
244                ExecutionCommitStatus::ResultRecorded,
245                0,
246                HistoryShaping { virtualised: false, deduped_bytes },
247            );
248        }
249        let events = trace.execution_events(&request.request_id);
250        assert!(events[0].deduped && events[0].deduped_bytes == 7_900);
251        let wire = serde_json::to_value(&events[0]).unwrap();
252        assert_eq!((wire["deduped"].clone(), wire["deduped_bytes"].clone()), (true.into(), 7_900.into()));
253        let legacy = serde_json::to_value(&events[1]).unwrap();
254        assert!(legacy.get("deduped").is_none() && legacy.get("deduped_bytes").is_none(), "{legacy}");
255        let back: ToolExecutionEvent = serde_json::from_value(legacy).unwrap();
256        assert!(!back.deduped && back.deduped_bytes == 0, "old records still read");
257    }
258
259    #[test]
260    fn execution_event_is_fed_into_same_request_trace_envelope() {
261        let sink = CollectingTraceSink::new();
262        let ctx = TraceContext::with_sink(sink.clone());
263        let request = ctx.reserve_request_correlation().unwrap();
264        let tracer = RequestTracer::begin(
265            &ctx,
266            Some(request.clone()),
267            agent_core::prompt::QualifiedModelId::parse("anthropic/claude-test").unwrap(),
268            TransportKind::AnthropicMessages,
269            EndpointMeta::new("api.anthropic.com", "/v1/messages").unwrap(),
270            RequestStructure::default(),
271        )
272        .unwrap();
273        tracer.finish(
274            AttemptClock::start(),
275            Some(200),
276            None,
277            None,
278            None,
279            TurnOutcome::Completed,
280        );
281        let correlation = ExecutionCorrelation::from_request(&ctx, &request);
282        correlation.record(
283            "toolu_1",
284            &crate::tools::catalog::ToolId::builtin("bash"),
285            "bash",
286            ExecutionPhase::ResultRecorded,
287            std::time::Instant::now(),
288            42,
289            8,
290            crate::tools::activation::ActivationBasis::Core,
291            crate::tools::catalog::ToolEffect::NonIdempotent,
292            ExecutionCommitStatus::ResultRecorded,
293            0,
294        );
295        let records = sink.records();
296        let enriched = records.last().expect("enriched trace");
297        assert_eq!(enriched.request_id, request.request_id);
298        assert_eq!(enriched.execution_events.len(), 1);
299        assert_eq!(enriched.execution_events[0].turn_id, enriched.turn_id);
300        assert_eq!(enriched.execution_events[0].session_id, enriched.session_id);
301    }
302
303    #[test]
304    fn metadata_serialization_never_contains_result_secret_sentinel() {
305        const SECRET: &str = "PH4-EXECUTION-SECRET-SENTINEL-c14d";
306        let sink = CollectingTraceSink::new();
307        let trace = TraceContext::with_sink(sink);
308        let request = trace.reserve_request_correlation().unwrap();
309        let correlation = ExecutionCorrelation::from_request(&trace, &request);
310        correlation.record(
311            "toolu_1",
312            &ToolId::builtin("bash"),
313            "bash",
314            ExecutionPhase::ResultRecorded,
315            Instant::now(),
316            SECRET.len(),
317            8,
318            ActivationBasis::Core,
319            ToolEffect::NonIdempotent,
320            ExecutionCommitStatus::ResultRecorded,
321            0,
322        );
323        let encoded = serde_json::to_string(&trace.execution_events(&request.request_id)).unwrap();
324        assert!(!encoded.contains(SECRET));
325    }
326
327    #[test]
328    fn hostile_unbounded_call_id_is_omitted() {
329        let sink = CollectingTraceSink::new();
330        let trace = TraceContext::with_sink(sink);
331        let request = trace.reserve_request_correlation().unwrap();
332        let correlation = ExecutionCorrelation::from_request(&trace, &request);
333        correlation.record(
334            &"x".repeat(super::super::TRACE_ID_MAX_BYTES + 1),
335            &ToolId::builtin("bash"),
336            "bash",
337            ExecutionPhase::Planned,
338            Instant::now(),
339            0,
340            0,
341            ActivationBasis::Core,
342            ToolEffect::NonIdempotent,
343            ExecutionCommitStatus::NotStarted,
344            0,
345        );
346        assert!(trace.execution_events(&request.request_id).is_empty());
347    }
348}