agent_engine/runtime/trace/
execution.rs1use 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
25impl ExecutionCorrelation {
26 pub fn from_request(trace: &TraceContext, request: &super::RequestCorrelation) -> Self {
27 Self {
28 trace: trace.clone(),
29 session_id: request.session_id.clone(),
30 turn_id: request.turn_id.clone(),
31 request_id: request.request_id.clone(),
32 }
33 }
34
35 #[allow(clippy::too_many_arguments)]
36 pub fn record(
37 &self,
38 tool_call_id: &str,
39 stable_tool_id: &ToolId,
40 wire_name: &str,
41 phase: ExecutionPhase,
42 started: Instant,
43 result_bytes: usize,
44 retained_bytes: usize,
45 activation: ActivationBasis,
46 effect: ToolEffect,
47 commit_status: ExecutionCommitStatus,
48 model_order: usize,
49 ) {
50 let Ok(tool_call_id) = TraceId::new(tool_call_id) else {
51 return;
52 };
53 let Ok(stable_tool_id) = TraceId::new(stable_tool_id.as_str()) else {
54 return;
55 };
56 let Ok(wire_name) = WireName::new(wire_name) else {
57 return;
58 };
59 let activation = match activation {
60 ActivationBasis::Core => ActivationGrantRef::Core,
61 ActivationBasis::Exact { catalog_generation } => ActivationGrantRef::Exact {
62 catalog_generation: catalog_generation.value(),
63 },
64 };
65 let effect = match effect {
66 ToolEffect::ReadOnly => ExecutionEffect::ReadOnly,
67 ToolEffect::IdempotentWrite => ExecutionEffect::IdempotentWrite,
68 ToolEffect::NonIdempotent => ExecutionEffect::NonIdempotent,
69 };
70 let event = ToolExecutionEvent {
71 session_id: self.session_id.clone(),
72 turn_id: self.turn_id.clone(),
73 request_id: self.request_id.clone(),
74 tool_call_id,
75 stable_tool_id,
76 wire_name,
77 phase,
78 elapsed_ms: started.elapsed().as_millis() as u64,
79 result_bytes: result_bytes as u64,
80 truncated: retained_bytes < result_bytes,
81 preview_bytes: retained_bytes as u64,
82 activation,
83 effect,
84 commit_status,
85 model_order: model_order as u32,
86 };
87 self.trace.record_execution_event(event);
88 self.trace.emit_execution_enriched(&self.request_id);
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use crate::runtime::trace::{
96 AttemptClock, CollectingTraceSink, EndpointMeta, RequestStructure, RequestTracer,
97 TransportKind,
98 };
99 use crate::TurnOutcome;
100
101 #[test]
102 fn records_only_bounded_metadata_with_consistent_ids() {
103 let sink = CollectingTraceSink::new();
104 let trace = TraceContext::with_sink(sink);
105 let request = trace.reserve_request_correlation().unwrap();
106 let correlation = ExecutionCorrelation::from_request(&trace, &request);
107 correlation.record(
108 "toolu_1",
109 &ToolId::builtin("bash"),
110 "bash",
111 ExecutionPhase::ResultRecorded,
112 Instant::now(),
113 9000,
114 32,
115 ActivationBasis::Core,
116 ToolEffect::NonIdempotent,
117 ExecutionCommitStatus::ResultRecorded,
118 0,
119 );
120 let events = trace.execution_events(&request.request_id);
121 assert_eq!(events.len(), 1);
122 let event = &events[0];
123 assert_eq!(event.session_id, request.session_id);
124 assert_eq!(event.turn_id, request.turn_id);
125 assert_eq!(event.request_id, request.request_id);
126 assert_eq!(event.preview_bytes, 32);
127 assert_eq!(event.result_bytes, 9000);
128 assert!(event.truncated);
129 let serialized = serde_json::to_string(event).unwrap();
130 assert!(!serialized.contains("raw result"));
131 }
132
133 #[test]
134 fn execution_event_is_fed_into_same_request_trace_envelope() {
135 let sink = CollectingTraceSink::new();
136 let ctx = TraceContext::with_sink(sink.clone());
137 let request = ctx.reserve_request_correlation().unwrap();
138 let tracer = RequestTracer::begin(
139 &ctx,
140 Some(request.clone()),
141 agent_core::prompt::QualifiedModelId::parse("anthropic/claude-test").unwrap(),
142 TransportKind::AnthropicMessages,
143 EndpointMeta::new("api.anthropic.com", "/v1/messages").unwrap(),
144 RequestStructure::default(),
145 )
146 .unwrap();
147 tracer.finish(
148 AttemptClock::start(),
149 Some(200),
150 None,
151 None,
152 None,
153 TurnOutcome::Completed,
154 );
155 let correlation = ExecutionCorrelation::from_request(&ctx, &request);
156 correlation.record(
157 "toolu_1",
158 &crate::tools::catalog::ToolId::builtin("bash"),
159 "bash",
160 ExecutionPhase::ResultRecorded,
161 std::time::Instant::now(),
162 42,
163 8,
164 crate::tools::activation::ActivationBasis::Core,
165 crate::tools::catalog::ToolEffect::NonIdempotent,
166 ExecutionCommitStatus::ResultRecorded,
167 0,
168 );
169 let records = sink.records();
170 let enriched = records.last().expect("enriched trace");
171 assert_eq!(enriched.request_id, request.request_id);
172 assert_eq!(enriched.execution_events.len(), 1);
173 assert_eq!(enriched.execution_events[0].turn_id, enriched.turn_id);
174 assert_eq!(enriched.execution_events[0].session_id, enriched.session_id);
175 }
176
177 #[test]
178 fn metadata_serialization_never_contains_result_secret_sentinel() {
179 const SECRET: &str = "PH4-EXECUTION-SECRET-SENTINEL-c14d";
180 let sink = CollectingTraceSink::new();
181 let trace = TraceContext::with_sink(sink);
182 let request = trace.reserve_request_correlation().unwrap();
183 let correlation = ExecutionCorrelation::from_request(&trace, &request);
184 correlation.record(
185 "toolu_1",
186 &ToolId::builtin("bash"),
187 "bash",
188 ExecutionPhase::ResultRecorded,
189 Instant::now(),
190 SECRET.len(),
191 8,
192 ActivationBasis::Core,
193 ToolEffect::NonIdempotent,
194 ExecutionCommitStatus::ResultRecorded,
195 0,
196 );
197 let encoded = serde_json::to_string(&trace.execution_events(&request.request_id)).unwrap();
198 assert!(!encoded.contains(SECRET));
199 }
200
201 #[test]
202 fn hostile_unbounded_call_id_is_omitted() {
203 let sink = CollectingTraceSink::new();
204 let trace = TraceContext::with_sink(sink);
205 let request = trace.reserve_request_correlation().unwrap();
206 let correlation = ExecutionCorrelation::from_request(&trace, &request);
207 correlation.record(
208 &"x".repeat(super::super::TRACE_ID_MAX_BYTES + 1),
209 &ToolId::builtin("bash"),
210 "bash",
211 ExecutionPhase::Planned,
212 Instant::now(),
213 0,
214 0,
215 ActivationBasis::Core,
216 ToolEffect::NonIdempotent,
217 ExecutionCommitStatus::NotStarted,
218 0,
219 );
220 assert!(trace.execution_events(&request.request_id).is_empty());
221 }
222}