Skip to main content

agent_sdk_core/application/
trace.rs

1//! Derived trace views over durable journal records.
2//! These helpers answer lineage questions without introducing a trace store,
3//! payload parser, or host-owned conversation database.
4
5use crate::{
6    domain::{
7        AttemptId, ContextProjectionId, EffectId, MessageId, RunId, SessionId, ToolCallId, TurnId,
8    },
9    journal::{EventIndexProjection, JournalRecord, JournalRecordPayload},
10};
11
12#[derive(Clone, Debug, Default, Eq, PartialEq)]
13/// Derived evidence for one turn. Build this from journal records when a host
14/// needs to inspect the actions causally associated with a user message.
15pub struct TurnTrace {
16    /// Optional host-provided session identifier for grouping related turns.
17    pub session_id: Option<SessionId>,
18    /// Turn identifier for one loop turn within a run.
19    pub turn_id: Option<TurnId>,
20    /// Run identifiers causally associated with this trace.
21    pub run_ids: Vec<RunId>,
22    /// Attempt identifiers observed in this trace.
23    pub attempt_ids: Vec<AttemptId>,
24    /// Message identifiers observed in this trace.
25    pub message_ids: Vec<MessageId>,
26    /// Context projection identifiers observed in this trace.
27    pub context_projection_ids: Vec<ContextProjectionId>,
28    /// Effect identifiers observed in this trace.
29    pub effect_ids: Vec<EffectId>,
30    /// Tool call identifiers observed in this trace.
31    pub tool_call_ids: Vec<ToolCallId>,
32    /// Event-index projections stored alongside matching journal records.
33    pub event_indexes: Vec<EventIndexProjection>,
34    /// Matching durable journal records in journal order.
35    pub records: Vec<JournalRecord>,
36}
37
38impl TurnTrace {
39    /// Builds a trace for one turn id from durable journal records.
40    /// Matching is envelope based and does not inspect raw content.
41    pub fn from_records<'a>(
42        turn_id: &TurnId,
43        records: impl IntoIterator<Item = &'a JournalRecord>,
44    ) -> Self {
45        Self::from_matching_records(records, |record| record.turn_id.as_ref() == Some(turn_id))
46    }
47
48    fn from_matching_records<'a>(
49        records: impl IntoIterator<Item = &'a JournalRecord>,
50        matches: impl Fn(&JournalRecord) -> bool,
51    ) -> Self {
52        let mut trace = TurnTrace::default();
53        for record in records.into_iter().filter(|record| matches(record)) {
54            trace.push_record(record);
55        }
56        trace
57    }
58
59    fn push_record(&mut self, record: &JournalRecord) {
60        if self.session_id.is_none() {
61            self.session_id = record.session_id.clone();
62        }
63        if self.turn_id.is_none() {
64            self.turn_id = record.turn_id.clone();
65        }
66        push_unique(&mut self.run_ids, record.run_id.clone());
67        if let Some(attempt_id) = record.attempt_id.clone() {
68            push_unique(&mut self.attempt_ids, attempt_id);
69        }
70        self.push_payload_ids(&record.payload);
71        self.event_indexes.push(record.event_index.clone());
72        self.records.push(record.clone());
73    }
74
75    fn push_payload_ids(&mut self, payload: &JournalRecordPayload) {
76        match payload {
77            JournalRecordPayload::TurnLifecycle(record) => {
78                push_unique_opt(&mut self.message_ids, record.input_message_id.clone());
79                push_unique_opt(&mut self.message_ids, record.output_message_id.clone());
80                push_unique_opt(
81                    &mut self.context_projection_ids,
82                    record.context_projection_id.clone(),
83                );
84                for run_id in &record.run_ids {
85                    push_unique(&mut self.run_ids, run_id.clone());
86                }
87            }
88            JournalRecordPayload::ContextProjection(record) => {
89                push_unique(
90                    &mut self.context_projection_ids,
91                    record.projection_id.clone(),
92                );
93            }
94            JournalRecordPayload::Message(record) => {
95                push_unique(&mut self.message_ids, record.message_id.clone());
96            }
97            JournalRecordPayload::EffectIntent(record) => {
98                push_unique(&mut self.effect_ids, record.effect_id.clone());
99            }
100            JournalRecordPayload::EffectResult(record) => {
101                push_unique(&mut self.effect_ids, record.effect_id.clone());
102            }
103            JournalRecordPayload::Tool(record) => {
104                push_unique(&mut self.tool_call_ids, record.tool_call_id.clone());
105            }
106            _ => {}
107        }
108    }
109
110    /// Returns true when no journal records matched the requested trace.
111    pub fn is_empty(&self) -> bool {
112        self.records.is_empty()
113    }
114}
115
116#[derive(Clone, Debug, Eq, PartialEq)]
117/// Derived evidence for one run. This is a convenience view over the same
118/// journal records used by `TurnTrace`.
119pub struct RunTrace {
120    /// Run identifier used for lineage, filtering, replay, and dedupe.
121    pub run_id: Option<RunId>,
122    /// Optional host-provided session identifier for grouping related turns.
123    pub session_id: Option<SessionId>,
124    /// Turn traces observed for this run, in first-seen journal order.
125    pub turn_traces: Vec<TurnTrace>,
126    /// Matching durable journal records in journal order.
127    pub records: Vec<JournalRecord>,
128}
129
130impl RunTrace {
131    /// Builds a run trace from durable journal records.
132    pub fn from_records<'a>(
133        run_id: &RunId,
134        records: impl IntoIterator<Item = &'a JournalRecord>,
135    ) -> Self {
136        let matching = records
137            .into_iter()
138            .filter(|record| &record.run_id == run_id)
139            .cloned()
140            .collect::<Vec<_>>();
141        let mut trace = RunTrace {
142            run_id: Some(run_id.clone()),
143            session_id: matching.iter().find_map(|record| record.session_id.clone()),
144            turn_traces: Vec::new(),
145            records: matching.clone(),
146        };
147        for turn_id in ordered_turn_ids(&matching) {
148            trace
149                .turn_traces
150                .push(TurnTrace::from_records(&turn_id, matching.iter()));
151        }
152        trace
153    }
154}
155
156#[derive(Clone, Debug, Eq, PartialEq)]
157/// Derived session timeline grouped by turn. Hosts may persist conversation
158/// stores separately; this view is only a journal-derived trace index.
159pub struct SessionTimeline {
160    /// Optional host-provided session identifier for grouping related turns.
161    pub session_id: SessionId,
162    /// Turn traces observed for this session, in first-seen journal order.
163    pub turns: Vec<TurnTrace>,
164}
165
166impl SessionTimeline {
167    /// Builds a session timeline from durable journal records.
168    pub fn from_records<'a>(
169        session_id: &SessionId,
170        records: impl IntoIterator<Item = &'a JournalRecord>,
171    ) -> Self {
172        let matching = records
173            .into_iter()
174            .filter(|record| record.session_id.as_ref() == Some(session_id))
175            .cloned()
176            .collect::<Vec<_>>();
177        let turns = ordered_turn_ids(&matching)
178            .into_iter()
179            .map(|turn_id| TurnTrace::from_records(&turn_id, matching.iter()))
180            .collect();
181        Self {
182            session_id: session_id.clone(),
183            turns,
184        }
185    }
186}
187
188fn ordered_turn_ids(records: &[JournalRecord]) -> Vec<TurnId> {
189    let mut turn_ids = Vec::new();
190    for record in records {
191        if let Some(turn_id) = record.turn_id.clone() {
192            push_unique(&mut turn_ids, turn_id);
193        }
194    }
195    turn_ids
196}
197
198fn push_unique<T: Eq>(items: &mut Vec<T>, value: T) {
199    if !items.contains(&value) {
200        items.push(value);
201    }
202}
203
204fn push_unique_opt<T: Eq>(items: &mut Vec<T>, value: Option<T>) {
205    if let Some(value) = value {
206        push_unique(items, value);
207    }
208}