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
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}