Skip to main content

agent_client_protocol_conductor/
trace.rs

1//! Trace event types for the sequence diagram viewer.
2//!
3//! Events are serialized as newline-delimited JSON (`.jsons` files).
4//! The viewer loads these files to render interactive sequence diagrams.
5
6use std::collections::HashMap;
7use std::fs::OpenOptions;
8use std::io::{BufWriter, Write};
9use std::path::Path;
10use std::time::Instant;
11
12use agent_client_protocol::schema::SuccessorMessage;
13use agent_client_protocol::schema::v1::{
14    MessageMcpNotification, MessageMcpRequest, Notification as RpcNotification,
15    Request as RpcRequest, RequestId, Response as RpcResponse,
16};
17use agent_client_protocol::{
18    DynConnectTo, JsonRpcMessage, RawJsonRpcMessage, RawJsonRpcParams, Role, UntypedMessage,
19};
20use rustc_hash::FxHashMap;
21use serde::{Deserialize, Serialize};
22
23use crate::ComponentIndex;
24use crate::snoop::SnooperComponent;
25
26/// A trace event representing message flow between components.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "type", rename_all = "snake_case")]
29#[non_exhaustive]
30pub enum TraceEvent {
31    /// A JSON-RPC request from one component to another.
32    Request(RequestEvent),
33
34    /// A JSON-RPC response to a prior request.
35    Response(ResponseEvent),
36
37    /// A JSON-RPC notification (no response expected).
38    Notification(NotificationEvent),
39}
40
41/// Protocol type for messages.
42#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "snake_case")]
44#[non_exhaustive]
45pub enum Protocol {
46    /// Standard ACP protocol messages.
47    Acp,
48    /// MCP messages carried over ACP.
49    Mcp,
50}
51
52/// A JSON-RPC request from one component to another.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[non_exhaustive]
55pub struct RequestEvent {
56    /// Monotonic timestamp (seconds since trace start).
57    pub ts: f64,
58
59    /// Protocol: ACP or MCP.
60    pub protocol: Protocol,
61
62    /// Source component (e.g., "client", "proxy:0", "proxy:1", "agent").
63    pub from: String,
64
65    /// Destination component.
66    pub to: String,
67
68    /// JSON-RPC request ID (for correlating with response).
69    pub id: serde_json::Value,
70
71    /// JSON-RPC method name.
72    pub method: String,
73
74    /// ACP session ID, if known; omitted when no session context is available.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub session: Option<String>,
77
78    /// Full request params.
79    pub params: serde_json::Value,
80}
81
82/// A JSON-RPC response to a prior request.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[non_exhaustive]
85pub struct ResponseEvent {
86    /// Monotonic timestamp (seconds since trace start).
87    pub ts: f64,
88
89    /// Source component (who sent the response).
90    pub from: String,
91
92    /// Destination component (who receives the response).
93    pub to: String,
94
95    /// JSON-RPC request ID this responds to.
96    pub id: serde_json::Value,
97
98    /// True if this is an error response.
99    pub is_error: bool,
100
101    /// Response result or error object.
102    pub payload: serde_json::Value,
103}
104
105/// A JSON-RPC notification (no response expected).
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[non_exhaustive]
108pub struct NotificationEvent {
109    /// Monotonic timestamp (seconds since trace start).
110    pub ts: f64,
111
112    /// Protocol: ACP or MCP.
113    pub protocol: Protocol,
114
115    /// Source component.
116    pub from: String,
117
118    /// Destination component.
119    pub to: String,
120
121    /// JSON-RPC method name.
122    pub method: String,
123
124    /// ACP session ID, if known.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub session: Option<String>,
127
128    /// Full notification params.
129    pub params: serde_json::Value,
130}
131
132/// Trait for destinations that can receive trace events.
133pub trait WriteEvent: Send + 'static {
134    /// Write a trace event to the destination.
135    fn write_event(&mut self, event: &TraceEvent) -> std::io::Result<()>;
136}
137
138/// Writes trace events as newline-delimited JSON to a `Write` impl.
139pub(crate) struct EventWriter<W> {
140    writer: W,
141}
142
143impl<W: Write> EventWriter<W> {
144    pub fn new(writer: W) -> Self {
145        Self { writer }
146    }
147}
148
149impl<W: Write + Send + 'static> WriteEvent for EventWriter<W> {
150    fn write_event(&mut self, event: &TraceEvent) -> std::io::Result<()> {
151        serde_json::to_writer(&mut self.writer, event).map_err(std::io::Error::other)?;
152        self.writer.write_all(b"\n")?;
153        self.writer.flush()
154    }
155}
156
157/// Impl for UnboundedSender - sends events to a channel (useful for testing).
158impl WriteEvent for futures::channel::mpsc::UnboundedSender<TraceEvent> {
159    fn write_event(&mut self, event: &TraceEvent) -> std::io::Result<()> {
160        self.unbounded_send(event.clone())
161            .map_err(|e| std::io::Error::new(std::io::ErrorKind::BrokenPipe, e))
162    }
163}
164
165/// Writer for trace events.
166pub struct TraceWriter {
167    dest: Box<dyn WriteEvent>,
168    start_time: Instant,
169
170    /// When we trace a request, we store its id along with the
171    /// details here. When we see responses, we try to match them up.
172    request_details: FxHashMap<serde_json::Value, RequestDetails>,
173}
174
175impl std::fmt::Debug for TraceWriter {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        f.debug_struct("TraceWriter")
178            .field("start_time", &self.start_time)
179            .finish_non_exhaustive()
180    }
181}
182
183struct RequestDetails {
184    #[expect(dead_code)]
185    protocol: Protocol,
186
187    #[expect(dead_code)]
188    method: String,
189
190    request_from: ComponentIndex,
191    request_to: ComponentIndex,
192}
193
194impl TraceWriter {
195    /// Create a new trace writer from any WriteEvent destination.
196    pub fn new<D: WriteEvent>(dest: D) -> Self {
197        Self {
198            dest: Box::new(dest),
199            start_time: Instant::now(),
200            request_details: HashMap::default(),
201        }
202    }
203
204    /// Create a new trace writer that writes to a file path.
205    pub fn from_path(path: impl AsRef<Path>) -> std::io::Result<Self> {
206        let file = OpenOptions::new()
207            .create(true)
208            .write(true)
209            .truncate(true)
210            .open(path.as_ref())?;
211        Ok(Self::new(EventWriter::new(BufWriter::new(file))))
212    }
213
214    /// Get the elapsed time since trace start, in seconds.
215    fn elapsed(&self) -> f64 {
216        self.start_time.elapsed().as_secs_f64()
217    }
218
219    /// Write a trace event.
220    fn write_event(&mut self, event: &TraceEvent) {
221        // Ignore errors - tracing should not break the conductor
222        drop(self.dest.write_event(event));
223    }
224
225    /// Write a request event.
226    #[expect(clippy::too_many_arguments)]
227    fn request(
228        &mut self,
229        protocol: Protocol,
230        from: ComponentIndex,
231        to: ComponentIndex,
232        id: serde_json::Value,
233        method: String,
234        session: Option<String>,
235        params: serde_json::Value,
236    ) {
237        self.request_details.insert(
238            id.clone(),
239            RequestDetails {
240                protocol,
241                method: method.clone(),
242                request_from: from,
243                request_to: to,
244            },
245        );
246        self.write_event(&TraceEvent::Request(RequestEvent {
247            ts: self.elapsed(),
248            protocol,
249            from: format!("{from:?}"),
250            to: format!("{to:?}"),
251            id,
252            method,
253            session,
254            params,
255        }));
256    }
257
258    /// Write a response event.
259    fn response(
260        &mut self,
261        from: ComponentIndex,
262        to: ComponentIndex,
263        id: serde_json::Value,
264        is_error: bool,
265        payload: serde_json::Value,
266    ) {
267        self.write_event(&TraceEvent::Response(ResponseEvent {
268            ts: self.elapsed(),
269            from: format!("{from:?}"),
270            to: format!("{to:?}"),
271            id,
272            is_error,
273            payload,
274        }));
275    }
276
277    /// Write a notification event.
278    fn notification(
279        &mut self,
280        protocol: Protocol,
281        from: ComponentIndex,
282        to: ComponentIndex,
283        method: impl Into<String>,
284        session: Option<String>,
285        params: serde_json::Value,
286    ) {
287        self.write_event(&TraceEvent::Notification(NotificationEvent {
288            ts: self.elapsed(),
289            protocol,
290            from: format!("{from:?}"),
291            to: format!("{to:?}"),
292            method: method.into(),
293            session,
294            params,
295        }));
296    }
297
298    /// Trace a raw JSON-RPC message being sent from one component to another.
299    fn trace_message(&mut self, traced_message: TracedMessage) {
300        let TracedMessage {
301            component_index,
302            successor_index,
303            incoming,
304            message,
305        } = traced_message;
306
307        // We get every message going into or out of a proxy. This includes
308        // a fair number of duplicates: for example, if proxy P0 sends to P1,
309        // we'll get it as an *outgoing* message from P0 and an *incoming* message to P1.
310        // So we want to keep just one copy.
311        //
312        // We retain:
313        //
314        // * Incoming requests/notifications targeting a PROXY.
315        // * Incoming requests/notifications targeting the AGENT.
316
317        match message {
318            RawJsonRpcMessage::Request(req) => {
319                let MessageInfo {
320                    successor,
321                    id,
322                    protocol,
323                    method,
324                    params,
325                } = MessageInfo::from_request(req);
326
327                self.trace_request_or_notification(
328                    incoming,
329                    component_index,
330                    successor_index,
331                    successor,
332                    id,
333                    protocol,
334                    method,
335                    params,
336                );
337            }
338            RawJsonRpcMessage::Notification(notification) => {
339                let MessageInfo {
340                    successor,
341                    id,
342                    protocol,
343                    method,
344                    params,
345                } = MessageInfo::from_notification(notification);
346
347                self.trace_request_or_notification(
348                    incoming,
349                    component_index,
350                    successor_index,
351                    successor,
352                    id,
353                    protocol,
354                    method,
355                    params,
356                );
357            }
358            RawJsonRpcMessage::Response(resp) => {
359                // Lookup the response by its id.
360                // All of the messages we are intercepting go to our proxies,
361                // and we always assign them globally unique ids.
362                let (id, is_error, payload) = match resp {
363                    RpcResponse::Result { id, result } => (id, false, result),
364                    RpcResponse::Error { id, error } => {
365                        (id, true, serde_json::to_value(error).unwrap_or_default())
366                    }
367                };
368                let id = id_to_json(&id);
369                if let Some(RequestDetails {
370                    protocol: _,
371                    method: _,
372                    request_from,
373                    request_to,
374                }) = self.request_details.remove(&id)
375                {
376                    self.response(request_to, request_from, id, is_error, payload);
377                }
378            }
379        }
380    }
381
382    #[expect(clippy::too_many_arguments)]
383    fn trace_request_or_notification(
384        &mut self,
385        incoming: Incoming,
386        component_index: ComponentIndex,
387        successor_index: ComponentIndex,
388        successor: Successor,
389        id: Option<RequestId>,
390        protocol: Protocol,
391        method: String,
392        params: serde_json::Value,
393    ) {
394        let (from, to) = match (successor, incoming, component_index, successor_index) {
395            // An incoming request/notification to a proxy from its predecessor.
396            (Successor(false), Incoming(true), ComponentIndex::Proxy(proxy_index), _) => (
397                ComponentIndex::predecessor_of(proxy_index),
398                ComponentIndex::Proxy(proxy_index),
399            ),
400
401            // An incoming request/notification to any component from its successor.
402            //
403            // This includes incoming messages to the client in the case where we have no proxies.
404            (Successor(true), Incoming(true), component_index, successor_index) => {
405                (successor_index, component_index)
406            }
407
408            // An outgoing request/notification from a component to its successor
409            // *and* its successor is not a proxy.
410            //
411            // (If its successor is a proxy, we ignore it, because we'll also see the
412            // message in "incoming" form).
413            (Successor(true), Incoming(false), component_index, ComponentIndex::Agent) => {
414                (component_index, ComponentIndex::Agent)
415            }
416
417            _ => return,
418        };
419
420        match id {
421            Some(id) => {
422                self.request(protocol, from, to, id_to_json(&id), method, None, params);
423            }
424            None => {
425                self.notification(protocol, from, to, method, None, params);
426            }
427        }
428    }
429
430    /// Spawn a trace writer task.
431    ///
432    /// Returns a `TraceHandle` that can be cloned and used from multiple tasks,
433    /// and a future that should be spawned (e.g., via `with_spawned`).
434    pub(crate) fn spawn(
435        mut self: TraceWriter,
436    ) -> (
437        TraceHandle,
438        impl std::future::Future<Output = Result<(), agent_client_protocol::Error>>,
439    ) {
440        use futures::StreamExt;
441
442        let (tx, mut rx) = futures::channel::mpsc::unbounded();
443
444        let future = async move {
445            while let Some(event) = rx.next().await {
446                self.trace_message(event);
447            }
448            Ok(())
449        };
450
451        (TraceHandle { tx }, future)
452    }
453}
454
455/// A cloneable handle for sending trace events to the trace writer task.
456///
457/// Create with [`spawn_trace_writer`], then clone and pass to bridges.
458#[derive(Clone, Debug)]
459pub(crate) struct TraceHandle {
460    tx: futures::channel::mpsc::UnboundedSender<TracedMessage>,
461}
462
463impl TraceHandle {
464    /// Trace a raw JSON-RPC message being sent from one component to another.
465    fn trace_message(
466        &self,
467        component_index: ComponentIndex,
468        successor_index: ComponentIndex,
469        incoming: Incoming,
470        message: &RawJsonRpcMessage,
471    ) -> Result<(), agent_client_protocol::Error> {
472        self.tx
473            .unbounded_send(TracedMessage {
474                component_index,
475                successor_index,
476                incoming,
477                message: message.clone(),
478            })
479            .map_err(agent_client_protocol::util::internal_error)
480    }
481
482    /// Create a tracing bridge that wraps a proxy component.
483    ///
484    /// Spawns a bridge task that forwards messages between the channel and the component
485    /// while tracing them. Returns the wrapped component.
486    ///
487    /// Tracing strategy:
488    /// - **Left→Right (incoming)**: Trace requests/notifications, skip responses
489    /// - **Right→Left (outgoing)**: Trace responses, and if `trace_outgoing_requests` is true,
490    ///   also trace requests/notifications (needed for edge bridges at conductor boundaries)
491    ///
492    /// - `cx`: Connection context for spawning the bridge task
493    /// - `left_name`: Logical name of the component on the "left" side (e.g., "client", "proxy:0")
494    /// - `right_name`: Logical name of the component on the "right" side (e.g., "proxy:0", "agent")
495    /// - `component`: The component to wrap
496    pub fn bridge_component<R: Role>(
497        &self,
498        proxy_index: ComponentIndex,
499        successor_index: ComponentIndex,
500        proxy: impl agent_client_protocol::ConnectTo<R>,
501    ) -> DynConnectTo<R> {
502        DynConnectTo::new(SnooperComponent::new(
503            proxy,
504            {
505                let trace_handle = self.clone();
506                move |msg| {
507                    trace_handle.trace_message(proxy_index, successor_index, Incoming(true), msg)
508                }
509            },
510            {
511                let trace_handle = self.clone();
512                move |msg| {
513                    trace_handle.trace_message(proxy_index, successor_index, Incoming(false), msg)
514                }
515            },
516        ))
517    }
518}
519
520/// Convert a JSON-RPC id to serde_json::Value.
521fn id_to_json(id: &RequestId) -> serde_json::Value {
522    serde_json::to_value(id).expect("RequestId serializes infallibly")
523}
524
525fn params_from_transport(params: Option<RawJsonRpcParams>) -> serde_json::Value {
526    params.map_or(serde_json::Value::Null, RawJsonRpcParams::into_value)
527}
528
529/// A message observed going over a channel connected to `left` and `right`.
530/// This could be a successor message, a mcp-over-acp message, etc.
531#[derive(Debug)]
532struct TracedMessage {
533    component_index: ComponentIndex,
534    successor_index: ComponentIndex,
535    incoming: Incoming,
536    message: RawJsonRpcMessage,
537}
538
539/// Fully interpreted message info.
540#[derive(Debug)]
541struct MessageInfo {
542    successor: Successor,
543    id: Option<RequestId>,
544    protocol: Protocol,
545    method: String,
546    params: serde_json::Value,
547}
548
549#[derive(Copy, Clone, Debug)]
550struct Successor(bool);
551
552#[derive(Copy, Clone, Debug)]
553struct Incoming(bool);
554
555impl MessageInfo {
556    /// Extract logical message info from method and params.
557    ///
558    /// This unwraps protocol wrappers to get the "real" message:
559    /// - `_proxy/successor` messages are unwrapped to get the inner message
560    /// - `mcp/message` messages are detected and marked as MCP protocol
561    ///
562    /// Returns (protocol, method, params).
563    fn from_request(req: RpcRequest<RawJsonRpcParams>) -> Self {
564        let untyped =
565            UntypedMessage::parse_message(&req.method, &params_from_transport(req.params))
566                .expect("untyped message is infallible");
567        Self::from_untyped_request(Successor(false), Some(req.id), Protocol::Acp, untyped)
568    }
569
570    fn from_notification(notification: RpcNotification<RawJsonRpcParams>) -> Self {
571        let untyped = UntypedMessage::parse_message(
572            &notification.method,
573            &params_from_transport(notification.params),
574        )
575        .expect("untyped message is infallible");
576        Self::from_untyped_notification(Successor(false), Protocol::Acp, untyped)
577    }
578
579    fn from_untyped_request(
580        successor: Successor,
581        id: Option<RequestId>,
582        protocol: Protocol,
583        untyped: UntypedMessage,
584    ) -> Self {
585        if let Ok(m) = SuccessorMessage::parse_message(&untyped.method, &untyped.params) {
586            return Self::from_untyped_request(Successor(true), id, protocol, m.message);
587        }
588
589        if let Ok(m) = MessageMcpRequest::parse_message(&untyped.method, &untyped.params) {
590            let params = m
591                .params
592                .map_or(serde_json::Value::Null, serde_json::Value::Object);
593            return Self::from_untyped_request(
594                successor,
595                id,
596                Protocol::Mcp,
597                UntypedMessage {
598                    method: m.method,
599                    params,
600                },
601            );
602        }
603
604        Self::new(successor, id, protocol, untyped)
605    }
606
607    fn from_untyped_notification(
608        successor: Successor,
609        protocol: Protocol,
610        untyped: UntypedMessage,
611    ) -> Self {
612        if let Ok(m) = SuccessorMessage::parse_message(&untyped.method, &untyped.params) {
613            return Self::from_untyped_notification(Successor(true), protocol, m.message);
614        }
615
616        if let Ok(m) = MessageMcpNotification::parse_message(&untyped.method, &untyped.params) {
617            let params = m
618                .params
619                .map_or(serde_json::Value::Null, serde_json::Value::Object);
620            return Self::from_untyped_notification(
621                successor,
622                Protocol::Mcp,
623                UntypedMessage {
624                    method: m.method,
625                    params,
626                },
627            );
628        }
629
630        Self::new(successor, None, protocol, untyped)
631    }
632
633    fn new(
634        successor: Successor,
635        id: Option<RequestId>,
636        protocol: Protocol,
637        untyped: UntypedMessage,
638    ) -> Self {
639        Self {
640            successor,
641            id,
642            protocol,
643            method: untyped.method,
644            params: untyped.params,
645        }
646    }
647}
648
649#[cfg(test)]
650mod tests {
651    use agent_client_protocol::RawJsonRpcMessage;
652    use serde_json::json;
653
654    use super::{MessageInfo, Protocol};
655
656    #[test]
657    fn tolerant_mcp_notification_params_are_traced_as_mcp() {
658        let RawJsonRpcMessage::Notification(notification) = RawJsonRpcMessage::notification(
659            "mcp/message".into(),
660            json!({
661                "connectionId": "connection-1",
662                "method": "notifications/progress",
663                "params": ["invalid named params"]
664            }),
665        )
666        .expect("notification is valid JSON-RPC") else {
667            unreachable!("notification constructor returned a different message kind")
668        };
669
670        let info = MessageInfo::from_notification(notification);
671
672        assert_eq!(info.protocol, Protocol::Mcp);
673        assert_eq!(info.method, "notifications/progress");
674        assert_eq!(info.params, serde_json::Value::Null);
675    }
676}