Skip to main content

agent_client_protocol_conductor/
lib.rs

1//! # agent-client-protocol-conductor
2//!
3//! Binary for orchestrating ACP proxy chains.
4//!
5//! ## What is the conductor?
6//!
7//! The conductor is a tool that manages proxy chains - it spawns proxy components and the base agent,
8//! then routes messages between them. From the editor's perspective, the conductor appears as a single ACP agent.
9//!
10//! ```text
11//! Editor ← stdio → Conductor → Proxy 1 → Proxy 2 → Agent
12//! ```
13//!
14//! ## Usage
15//!
16//! ### Agent Mode
17//!
18//! Orchestrate a chain of proxies in front of an agent:
19//!
20//! ```bash
21//! # Chain format: proxy1 proxy2 ... agent
22//! agent-client-protocol-conductor agent "python proxy1.py" "python proxy2.py" "python base-agent.py"
23//! ```
24//!
25//! The conductor:
26//! 1. Spawns each component as a subprocess
27//! 2. Connects them in a chain
28//! 3. Presents as a single agent on stdin/stdout
29//! 4. Manages the lifecycle of all processes
30//!
31//! ## How It Works
32//!
33//! **Component Communication:**
34//! - Editor talks to conductor via stdio
35//! - Conductor uses the `_proxy/successor` envelope to route messages
36//! - Each proxy can intercept, transform, or forward messages
37//! - Final agent receives standard ACP messages
38//!
39//! **Process Management:**
40//! - All components are spawned as child processes
41//! - When conductor exits, all children are terminated
42//! - Errors in any component bring down the entire chain
43//!
44//! ## Example Use Case
45//!
46//! Add Sparkle embodiment + custom tools to any agent:
47//!
48//! ```bash
49//! agent-client-protocol-conductor agent \
50//!   "sparkle-acp-proxy" \
51//!   "my-custom-tools-proxy" \
52//!   "claude-agent"
53//! ```
54//!
55//! This creates a stack where:
56//! 1. Sparkle proxy injects MCP servers and prepends embodiment
57//! 2. Custom tools proxy adds domain-specific functionality
58//! 3. Base agent handles the actual AI responses
59//!
60//! ## Related Crates
61//!
62//! - **[agent-client-protocol](https://crates.io/crates/agent-client-protocol)** - Core ACP SDK
63//! - **[agent-client-protocol-polyfill](https://crates.io/crates/agent-client-protocol-polyfill)** - Compatibility proxies, including the native MCP-over-ACP to HTTP adapter
64//! - **[agent-client-protocol-trace-viewer](https://crates.io/crates/agent-client-protocol-trace-viewer)** - Interactive trace visualization
65
66use std::path::PathBuf;
67use std::str::FromStr;
68
69/// Core conductor logic for orchestrating proxy chains
70mod conductor;
71/// Debug logging for conductor
72mod debug_logger;
73mod snoop;
74/// Trace event types for sequence diagram viewer
75pub mod trace;
76
77pub use self::conductor::*;
78
79use clap::{Parser, Subcommand};
80
81#[cfg(feature = "unstable_protocol_v2")]
82use agent_client_protocol::schema::v2;
83use agent_client_protocol::{AcpAgent, Stdio};
84use agent_client_protocol::{Client, Conductor, DynConnectTo, schema::v1::InitializeRequest};
85use tracing::Instrument;
86use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
87
88/// Wrapper for command-line component lists that can serve as either
89/// proxies-only (for proxy mode) or proxies+agent (for agent mode).
90///
91/// This exists because `AcpAgent` implements `ConnectTo<Client>` and
92/// `ConnectTo<Conductor>`, so a `Vec<AcpAgent>` can be used as either a list
93/// of proxies or as proxies + final agent depending on the conductor mode.
94#[derive(Debug)]
95pub struct CommandLineComponents(pub Vec<AcpAgent>);
96
97impl InstantiateProxies for CommandLineComponents {
98    fn instantiate_proxies(
99        self: Box<Self>,
100        req: InitializeRequest,
101    ) -> futures::future::BoxFuture<
102        'static,
103        Result<(InitializeRequest, Vec<DynConnectTo<Conductor>>), agent_client_protocol::Error>,
104    > {
105        Box::pin(async move {
106            let proxies = self.0.into_iter().map(DynConnectTo::new).collect();
107            Ok((req, proxies))
108        })
109    }
110
111    #[cfg(feature = "unstable_protocol_v2")]
112    fn instantiate_v2_proxies(
113        self: Box<Self>,
114        req: v2::InitializeRequest,
115    ) -> futures::future::BoxFuture<
116        'static,
117        Result<(v2::InitializeRequest, Vec<DynConnectTo<Conductor>>), agent_client_protocol::Error>,
118    > {
119        Box::pin(async move {
120            let proxies = self.0.into_iter().map(DynConnectTo::new).collect();
121            Ok((req, proxies))
122        })
123    }
124}
125
126impl InstantiateProxiesAndAgent for CommandLineComponents {
127    fn instantiate_proxies_and_agent(
128        self: Box<Self>,
129        req: InitializeRequest,
130    ) -> futures::future::BoxFuture<
131        'static,
132        Result<
133            (
134                InitializeRequest,
135                Vec<DynConnectTo<Conductor>>,
136                DynConnectTo<Client>,
137            ),
138            agent_client_protocol::Error,
139        >,
140    > {
141        Box::pin(async move {
142            let mut iter = self.0.into_iter().peekable();
143            let mut proxies: Vec<DynConnectTo<Conductor>> = Vec::new();
144
145            // All but the last element are proxies
146            while let Some(component) = iter.next() {
147                if iter.peek().is_some() {
148                    proxies.push(DynConnectTo::new(component));
149                } else {
150                    // Last element is the agent
151                    let agent = DynConnectTo::new(component);
152                    return Ok((req, proxies, agent));
153                }
154            }
155
156            Err(agent_client_protocol::util::internal_error(
157                "no agent component in list",
158            ))
159        })
160    }
161
162    #[cfg(feature = "unstable_protocol_v2")]
163    fn instantiate_v2_proxies_and_agent(
164        self: Box<Self>,
165        req: v2::InitializeRequest,
166    ) -> futures::future::BoxFuture<
167        'static,
168        Result<
169            (
170                v2::InitializeRequest,
171                Vec<DynConnectTo<Conductor>>,
172                DynConnectTo<Client>,
173            ),
174            agent_client_protocol::Error,
175        >,
176    > {
177        Box::pin(async move {
178            let mut iter = self.0.into_iter().peekable();
179            let mut proxies = Vec::new();
180
181            while let Some(component) = iter.next() {
182                if iter.peek().is_some() {
183                    proxies.push(DynConnectTo::new(component));
184                } else {
185                    return Ok((req, proxies, DynConnectTo::new(component)));
186                }
187            }
188
189            Err(agent_client_protocol::util::internal_error(
190                "no agent component in list",
191            ))
192        })
193    }
194}
195
196/// Wrapper to implement WriteEvent for TraceHandle.
197struct TraceHandleWriter(agent_client_protocol_trace_viewer::TraceHandle);
198
199impl trace::WriteEvent for TraceHandleWriter {
200    fn write_event(&mut self, event: &trace::TraceEvent) -> std::io::Result<()> {
201        let value = serde_json::to_value(event).map_err(std::io::Error::other)?;
202        self.0.push(value);
203        Ok(())
204    }
205}
206
207#[derive(Parser, Debug)]
208#[command(author, version, about, long_about = None)]
209pub struct ConductorArgs {
210    /// Enable debug logging of all stdin/stdout/stderr from components
211    #[arg(long)]
212    pub debug: bool,
213
214    /// Directory for debug log files (defaults to current directory)
215    #[arg(long)]
216    pub debug_dir: Option<PathBuf>,
217
218    /// Set log level (e.g., "trace", "debug", "info", "warn", "error", or module-specific like "conductor=debug")
219    /// Only applies when --debug is enabled
220    #[arg(long)]
221    pub log: Option<String>,
222
223    /// Path to write trace events for sequence diagram visualization.
224    /// Events are written as newline-delimited JSON (.jsons format).
225    #[arg(long)]
226    pub trace: Option<PathBuf>,
227
228    /// Serve trace viewer in browser with live updates.
229    /// Can be used alone (in-memory) or with --trace (file-backed).
230    #[arg(long)]
231    pub serve: bool,
232
233    #[command(subcommand)]
234    pub command: ConductorCommand,
235}
236
237#[derive(Subcommand, Debug)]
238pub enum ConductorCommand {
239    /// Run as agent orchestrator managing a proxy chain
240    Agent {
241        /// Name of the agent
242        #[arg(short, long, default_value = "conductor")]
243        name: String,
244
245        /// List of commands to chain together; the final command must be the agent.
246        components: Vec<String>,
247    },
248
249    /// Run as a proxy orchestrating a proxy chain
250    Proxy {
251        /// Name of the proxy
252        #[arg(short, long, default_value = "conductor")]
253        name: String,
254
255        /// List of proxy commands to chain together
256        proxies: Vec<String>,
257    },
258}
259
260impl ConductorArgs {
261    /// Main entry point that sets up tracing and runs the conductor
262    pub async fn main(self) -> anyhow::Result<()> {
263        let pid = std::process::id();
264        let cwd = std::env::current_dir()
265            .map_or_else(|_| "<unknown>".to_string(), |p| p.display().to_string());
266
267        // Only set up tracing if --debug is enabled
268        let debug_logger = if self.debug {
269            // Extract proxy list to create the debug logger
270            let components = match &self.command {
271                ConductorCommand::Agent { components, .. } => components.clone(),
272                ConductorCommand::Proxy { proxies, .. } => proxies.clone(),
273            };
274
275            // Create debug logger
276            Some(
277                debug_logger::DebugLogger::new(self.debug_dir.clone(), &components)
278                    .await
279                    .map_err(|e| anyhow::anyhow!("Failed to create debug logger: {e}"))?,
280            )
281        } else {
282            None
283        };
284
285        if let Some(debug_logger) = &debug_logger {
286            // Set up log level from --log flag, defaulting to "info"
287            let log_level = self.log.as_deref().unwrap_or("info");
288
289            // Set up tracing to write to the debug file with "C !" prefix
290            let tracing_writer = debug_logger.create_tracing_writer();
291            tracing_subscriber::registry()
292                .with(EnvFilter::new(log_level))
293                .with(
294                    tracing_subscriber::fmt::layer()
295                        .with_target(true)
296                        .with_writer(move || tracing_writer.clone()),
297                )
298                .init();
299
300            tracing::info!(pid = %pid, cwd = %cwd, level = %log_level, "Conductor starting with debug logging");
301        }
302
303        // Set up tracing based on --trace and --serve flags
304        let (trace_writer, _viewer_server) = match (&self.trace, self.serve) {
305            // --trace only: write to file
306            (Some(trace_path), false) => {
307                let writer = trace::TraceWriter::from_path(trace_path)
308                    .map_err(|e| anyhow::anyhow!("Failed to create trace writer: {e}"))?;
309                (Some(writer), None)
310            }
311            // --serve only: in-memory with viewer
312            (None, true) => {
313                let (handle, server) = agent_client_protocol_trace_viewer::serve_memory(
314                    agent_client_protocol_trace_viewer::TraceViewerConfig::default(),
315                )?;
316                let writer = trace::TraceWriter::new(TraceHandleWriter(handle));
317                (Some(writer), Some(tokio::spawn(server)))
318            }
319            // --trace --serve: write to file and serve it
320            (Some(trace_path), true) => {
321                let writer = trace::TraceWriter::from_path(trace_path)
322                    .map_err(|e| anyhow::anyhow!("Failed to create trace writer: {e}"))?;
323                let server = agent_client_protocol_trace_viewer::serve_file(
324                    trace_path.clone(),
325                    agent_client_protocol_trace_viewer::TraceViewerConfig::default(),
326                );
327                (Some(writer), Some(tokio::spawn(server)))
328            }
329            // Neither: no tracing
330            (None, false) => (None, None),
331        };
332
333        self.run(debug_logger.as_ref(), trace_writer)
334            .instrument(tracing::info_span!("conductor", pid = %pid, cwd = %cwd))
335            .await
336            .map_err(|err| anyhow::anyhow!("{err}"))
337    }
338
339    async fn run(
340        self,
341        debug_logger: Option<&debug_logger::DebugLogger>,
342        trace_writer: Option<trace::TraceWriter>,
343    ) -> Result<(), agent_client_protocol::Error> {
344        match self.command {
345            ConductorCommand::Agent { name, components } => {
346                initialize_conductor(
347                    debug_logger,
348                    trace_writer,
349                    name,
350                    components,
351                    ConductorImpl::new_agent,
352                )
353                .await
354            }
355            ConductorCommand::Proxy { name, proxies } => {
356                initialize_conductor(
357                    debug_logger,
358                    trace_writer,
359                    name,
360                    proxies,
361                    ConductorImpl::new_proxy,
362                )
363                .await
364            }
365        }
366    }
367}
368
369async fn initialize_conductor<Host: ConductorHostRole>(
370    debug_logger: Option<&debug_logger::DebugLogger>,
371    trace_writer: Option<trace::TraceWriter>,
372    name: String,
373    components: Vec<String>,
374    new_conductor: impl FnOnce(String, CommandLineComponents) -> ConductorImpl<Host>,
375) -> Result<(), agent_client_protocol::Error> {
376    // Parse agents and optionally wrap with debug callbacks
377    let providers: Vec<AcpAgent> = components
378        .into_iter()
379        .enumerate()
380        .map(|(i, s)| {
381            let mut agent = AcpAgent::from_str(&s)?;
382            if let Some(logger) = debug_logger {
383                agent = agent.with_debug(logger.create_callback(i.to_string()));
384            }
385            Ok(agent)
386        })
387        .collect::<Result<Vec<_>, agent_client_protocol::Error>>()?;
388
389    // Create Stdio component with optional debug logging
390    let stdio = if let Some(logger) = debug_logger {
391        Stdio::new().with_debug(logger.create_callback("C".to_string()))
392    } else {
393        Stdio::new()
394    };
395
396    // Create conductor with optional trace writer
397    let mut conductor = new_conductor(name, CommandLineComponents(providers));
398    if let Some(writer) = trace_writer {
399        conductor = conductor.with_trace_writer(writer);
400    }
401
402    conductor.run(stdio).await
403}