Skip to main content

a3s_code_core/
trace.rs

1//! Runtime trace primitives.
2//!
3//! Trace events are compact execution facts emitted by the harness. They are
4//! separate from model-visible tool output and from large artifacts.
5
6use serde::{Deserialize, Serialize};
7use std::sync::{Arc, RwLock};
8use std::time::Duration;
9
10pub const TRACE_EVENT_SCHEMA: &str = "a3s.trace_event.v1";
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum TraceEventKind {
15    ToolExecution,
16    ProgramExecution,
17}
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct TraceEvent {
21    pub schema: String,
22    pub kind: TraceEventKind,
23    pub name: String,
24    pub success: bool,
25    pub exit_code: i32,
26    pub duration_ms: u64,
27    pub output_bytes: usize,
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub metadata_keys: Vec<String>,
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub artifact_uris: Vec<String>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub details: Option<serde_json::Value>,
34}
35
36impl TraceEvent {
37    pub fn tool_execution(
38        name: impl Into<String>,
39        success: bool,
40        exit_code: i32,
41        duration: Duration,
42        output_bytes: usize,
43        metadata: Option<&serde_json::Value>,
44    ) -> Self {
45        Self {
46            schema: TRACE_EVENT_SCHEMA.to_string(),
47            kind: TraceEventKind::ToolExecution,
48            name: name.into(),
49            success,
50            exit_code,
51            duration_ms: duration.as_millis().min(u128::from(u64::MAX)) as u64,
52            output_bytes,
53            metadata_keys: metadata_keys(metadata),
54            artifact_uris: artifact_uris(metadata),
55            details: None,
56        }
57    }
58
59    pub fn program_execution(
60        name: impl Into<String>,
61        success: bool,
62        exit_code: i32,
63        duration: Duration,
64        output_bytes: usize,
65        metadata: Option<&serde_json::Value>,
66    ) -> Self {
67        let details = metadata
68            .and_then(|metadata| metadata.get("trace"))
69            .map(program_trace_summary);
70
71        Self {
72            schema: TRACE_EVENT_SCHEMA.to_string(),
73            kind: TraceEventKind::ProgramExecution,
74            name: name.into(),
75            success,
76            exit_code,
77            duration_ms: duration.as_millis().min(u128::from(u64::MAX)) as u64,
78            output_bytes,
79            metadata_keys: metadata_keys(metadata),
80            artifact_uris: artifact_uris(metadata),
81            details,
82        }
83    }
84}
85
86pub trait TraceSink: Send + Sync {
87    fn record(&self, event: TraceEvent);
88}
89
90#[derive(Debug, Clone, Default)]
91pub struct InMemoryTraceSink {
92    events: Arc<RwLock<Vec<TraceEvent>>>,
93    /// FIFO retention cap (`None` = unlimited). When set, the oldest
94    /// event is dropped on each new `record` once the buffer exceeds
95    /// this size. Useful for long-running sessions that would
96    /// otherwise leak trace memory.
97    max_events: Option<usize>,
98}
99
100impl InMemoryTraceSink {
101    /// Construct a sink with no retention cap (default, unbounded).
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Construct a sink that retains at most `max_events` records.
107    pub fn with_max_events(max_events: usize) -> Self {
108        Self {
109            events: Arc::new(RwLock::new(Vec::with_capacity(max_events.min(1024)))),
110            max_events: Some(max_events),
111        }
112    }
113
114    pub fn events(&self) -> Vec<TraceEvent> {
115        self.events.read().unwrap().clone()
116    }
117
118    pub fn replace_events(&self, events: Vec<TraceEvent>) {
119        *self.events.write().unwrap() = events;
120    }
121
122    pub fn clear(&self) {
123        self.events.write().unwrap().clear();
124    }
125}
126
127impl TraceSink for InMemoryTraceSink {
128    fn record(&self, event: TraceEvent) {
129        let mut events = self.events.write().unwrap();
130        events.push(event);
131        // FIFO trim — keep the buffer at most `max_events`. We drain
132        // from the front rather than truncating the back so the most
133        // recent entries (most useful for debugging) are preserved.
134        // Steady-state cost is one O(n) shift per push at cap; acceptable
135        // for diagnostic traces. Switch to VecDeque if hot-path tracing
136        // ever becomes a perf bottleneck.
137        if let Some(cap) = self.max_events {
138            if events.len() > cap {
139                let excess = events.len() - cap;
140                events.drain(..excess);
141            }
142        }
143    }
144}
145
146#[derive(Debug, Clone, Copy, Default)]
147pub struct NoopTraceSink;
148
149impl TraceSink for NoopTraceSink {
150    fn record(&self, _event: TraceEvent) {}
151}
152
153fn metadata_keys(metadata: Option<&serde_json::Value>) -> Vec<String> {
154    let Some(serde_json::Value::Object(object)) = metadata else {
155        return Vec::new();
156    };
157
158    let mut keys = object.keys().cloned().collect::<Vec<_>>();
159    keys.sort();
160    keys
161}
162
163fn artifact_uris(metadata: Option<&serde_json::Value>) -> Vec<String> {
164    let mut uris = Vec::new();
165    if let Some(metadata) = metadata {
166        collect_artifact_uris(metadata, &mut uris);
167    }
168    uris.sort();
169    uris.dedup();
170    uris
171}
172
173fn collect_artifact_uris(value: &serde_json::Value, uris: &mut Vec<String>) {
174    match value {
175        serde_json::Value::Object(object) => {
176            if let Some(uri) = object.get("artifact_uri").and_then(|value| value.as_str()) {
177                uris.push(uri.to_string());
178            }
179            for value in object.values() {
180                collect_artifact_uris(value, uris);
181            }
182        }
183        serde_json::Value::Array(items) => {
184            for value in items {
185                collect_artifact_uris(value, uris);
186            }
187        }
188        _ => {}
189    }
190}
191
192fn program_trace_summary(trace: &serde_json::Value) -> serde_json::Value {
193    serde_json::json!({
194        "program_name": trace.get("program_name").cloned().unwrap_or_default(),
195        "success": trace.get("success").cloned().unwrap_or_default(),
196        "step_count": trace.get("step_count").cloned().unwrap_or_default(),
197        "failed_steps": trace.get("failed_steps").cloned().unwrap_or_default(),
198    })
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn in_memory_trace_sink_records_events() {
207        let sink = InMemoryTraceSink::default();
208        sink.record(TraceEvent::tool_execution(
209            "read",
210            true,
211            0,
212            Duration::from_millis(7),
213            12,
214            Some(&serde_json::json!({
215                "artifact": {
216                    "artifact_uri": "a3s://tool-output/read/abc"
217                },
218                "file_path": "src/lib.rs"
219            })),
220        ));
221
222        let events = sink.events();
223
224        assert_eq!(events.len(), 1);
225        assert_eq!(events[0].schema, TRACE_EVENT_SCHEMA);
226        assert_eq!(events[0].kind, TraceEventKind::ToolExecution);
227        assert_eq!(events[0].metadata_keys, vec!["artifact", "file_path"]);
228        assert_eq!(events[0].artifact_uris, vec!["a3s://tool-output/read/abc"]);
229    }
230
231    #[test]
232    fn program_trace_event_stores_compact_summary() {
233        let event = TraceEvent::program_execution(
234            "program",
235            true,
236            0,
237            Duration::from_millis(3),
238            42,
239            Some(&serde_json::json!({
240                "trace": {
241                    "program_name": "program_repo_map",
242                    "success": true,
243                    "step_count": 7,
244                    "failed_steps": 0,
245                    "steps": [{"output": "not copied into event"}]
246                }
247            })),
248        );
249
250        assert_eq!(event.kind, TraceEventKind::ProgramExecution);
251        assert_eq!(
252            event.details.as_ref().unwrap()["program_name"],
253            "program_repo_map"
254        );
255        assert!(event.details.as_ref().unwrap().get("steps").is_none());
256    }
257
258    fn dummy_event(i: u32) -> TraceEvent {
259        TraceEvent::tool_execution(
260            "read",
261            true,
262            0,
263            Duration::from_millis(i as u64),
264            i as usize,
265            None,
266        )
267    }
268
269    #[test]
270    fn with_max_events_caps_buffer_fifo() {
271        let sink = InMemoryTraceSink::with_max_events(3);
272        for i in 0..10 {
273            sink.record(dummy_event(i));
274        }
275        let events = sink.events();
276        assert_eq!(events.len(), 3, "buffer must be capped");
277        // Oldest events are evicted; the surviving events are the
278        // last `cap` recorded (7, 8, 9).
279        assert_eq!(events[0].duration_ms, 7);
280        assert_eq!(events[2].duration_ms, 9);
281    }
282
283    #[test]
284    fn default_sink_is_unbounded() {
285        let sink = InMemoryTraceSink::new();
286        for i in 0..50 {
287            sink.record(dummy_event(i));
288        }
289        assert_eq!(sink.events().len(), 50);
290    }
291}