1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28pub struct HistoryShaping {
29 pub virtualised: bool,
32 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 #[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 #[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 #[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}