Skip to content

Commit 489b817

Browse files
committed
docs(sacp): add cookbook module with common patterns
- Add cookbook module documenting roles, endpoints, and common patterns - Extract patterns from lib.rs into dedicated submodules: - reusable_components: Defining agents/proxies with Component - custom_message_handlers: Implementing JrMessageHandler - connecting_as_client: Using with_client to send requests - global_mcp_server: Adding MCP servers to handler chains - per_session_mcp_server: Creating per-session MCP servers - Slim down lib.rs to reference cookbook instead of duplicating content
1 parent 0f05979 commit 489b817

2 files changed

Lines changed: 341 additions & 86 deletions

File tree

src/sacp/src/cookbook.rs

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
//! Cookbook of common patterns for building ACP components.
2+
//!
3+
//! This module contains documented examples of patterns that come up
4+
//! frequently when building agents, proxies, and other ACP components.
5+
//!
6+
//! # Roles and Endpoints
7+
//!
8+
//! ACP connections are typed by their *role*, which captures both "who I am"
9+
//! and "who I'm talking to". Roles implement [`JrRole`] and determine what
10+
//! operations are valid on a connection.
11+
//!
12+
//! ## Endpoints
13+
//!
14+
//! *Endpoints* ([`JrEndpoint`]) are logical destinations for messages:
15+
//!
16+
//! - [`Client`] - The client endpoint (IDE, CLI, etc.)
17+
//! - [`Agent`] - The agent endpoint (AI-powered component)
18+
//! - [`Conductor`] - The conductor endpoint (orchestrates proxy chains)
19+
//!
20+
//! Most roles have a single implicit endpoint, but proxies can send to
21+
//! multiple endpoints. Use [`send_request_to`] and [`send_notification_to`]
22+
//! to specify the destination explicitly.
23+
//!
24+
//! ## Role Types
25+
//!
26+
//! The built-in role types are:
27+
//!
28+
//! | Role | Description | Can send to |
29+
//! |------|-------------|-------------|
30+
//! | [`ClientToAgent`] | Client's connection to an agent | `Agent` |
31+
//! | [`AgentToClient`] | Agent's connection to a client | `Client` |
32+
//! | [`ProxyToConductor`] | Proxy's connection to the conductor | `Client`, `Agent` |
33+
//! | [`ConductorToClient`] | Conductor's connection to a client | `Client`, `Agent` |
34+
//! | [`ConductorToProxy`] | Conductor's connection to a proxy | `Agent` |
35+
//! | [`ConductorToAgent`] | Conductor's connection to the final agent | `Agent` |
36+
//! | [`UntypedRole`] | Generic role for testing/dynamic scenarios | any |
37+
//!
38+
//! ## Proxies and Multiple Endpoints
39+
//!
40+
//! A proxy sits between client and agent, so it needs to send messages in
41+
//! both directions. [`ProxyToConductor`] implements `HasEndpoint<Client>` and
42+
//! `HasEndpoint<Agent>`, allowing it to forward messages appropriately:
43+
//!
44+
//! ```ignore
45+
//! // Forward a request toward the agent
46+
//! cx.send_request_to(Agent, request).forward_to_request_cx(request_cx)?;
47+
//!
48+
//! // Send a notification toward the client
49+
//! cx.send_notification_to(Client, notification)?;
50+
//! ```
51+
//!
52+
//! When sending to `Agent` from a proxy, messages are automatically wrapped
53+
//! in [`SuccessorMessage`] envelopes. When receiving from `Agent`, they're
54+
//! automatically unwrapped.
55+
//!
56+
//! [`JrRole`]: crate::role::JrRole
57+
//! [`JrEndpoint`]: crate::role::JrEndpoint
58+
//! [`Client`]: crate::Client
59+
//! [`Agent`]: crate::Agent
60+
//! [`Conductor`]: crate::Conductor
61+
//! [`ClientToAgent`]: crate::ClientToAgent
62+
//! [`AgentToClient`]: crate::AgentToClient
63+
//! [`ProxyToConductor`]: crate::ProxyToConductor
64+
//! [`ConductorToClient`]: crate::role::ConductorToClient
65+
//! [`ConductorToProxy`]: crate::role::ConductorToProxy
66+
//! [`ConductorToAgent`]: crate::role::ConductorToAgent
67+
//! [`UntypedRole`]: crate::role::UntypedRole
68+
//! [`SuccessorMessage`]: crate::schema::SuccessorMessage
69+
//! [`send_request_to`]: crate::JrConnectionCx::send_request_to
70+
//! [`send_notification_to`]: crate::JrConnectionCx::send_notification_to
71+
//!
72+
//! # Patterns
73+
//!
74+
//! - [`reusable_components`] - Defining agents/proxies with [`Component`]
75+
//! - [`custom_message_handlers`] - Implementing [`JrMessageHandler`]
76+
//! - [`connecting_as_client`] - Using `with_client` to send requests
77+
//! - [`global_mcp_server`] - Adding a shared MCP server to a handler chain
78+
//! - [`per_session_mcp_server`] - Creating per-session MCP servers
79+
//!
80+
//! [`Component`]: crate::Component
81+
//! [`JrMessageHandler`]: crate::JrMessageHandler
82+
//! [`reusable_components`]: crate::cookbook::reusable_components
83+
//! [`custom_message_handlers`]: crate::cookbook::custom_message_handlers
84+
//! [`connecting_as_client`]: crate::cookbook::connecting_as_client
85+
//! [`global_mcp_server`]: crate::cookbook::global_mcp_server
86+
//! [`per_session_mcp_server`]: crate::cookbook::per_session_mcp_server
87+
88+
pub mod reusable_components {
89+
//! Pattern: Defining reusable components.
90+
//!
91+
//! When building agents or proxies, define a struct that implements [`Component`].
92+
//! Internally, use the role's `builder()` method to set up handlers.
93+
//!
94+
//! # Example
95+
//!
96+
//! ```ignore
97+
//! use sacp::{Component, AgentToClient};
98+
//! use sacp::schema::{PromptRequest, PromptResponse};
99+
//!
100+
//! struct MyAgent {
101+
//! config: AgentConfig,
102+
//! }
103+
//!
104+
//! impl Component for MyAgent {
105+
//! async fn serve(self, client: impl Component) -> Result<(), sacp::Error> {
106+
//! AgentToClient::builder()
107+
//! .name("my-agent")
108+
//! .on_receive_request(async move |req: PromptRequest, request_cx, cx| {
109+
//! let response = self.process_prompt(&req).await?;
110+
//! request_cx.respond(response)
111+
//! }, sacp::on_receive_request!())
112+
//! .serve(client)
113+
//! .await
114+
//! }
115+
//! }
116+
//! ```
117+
//!
118+
//! # Important: Don't block the event loop
119+
//!
120+
//! Message handlers run on the event loop. Blocking in a handler prevents the
121+
//! connection from processing new messages. For expensive work:
122+
//!
123+
//! - Use [`JrConnectionCx::spawn`] to offload work to a background task
124+
//! - Use [`on_receiving_result`] to schedule work when a response arrives
125+
//!
126+
//! [`Component`]: crate::Component
127+
//! [`JrConnectionCx::spawn`]: crate::JrConnectionCx::spawn
128+
//! [`on_receiving_result`]: crate::JrResponse::on_receiving_result
129+
}
130+
131+
pub mod custom_message_handlers {
132+
//! Pattern: Custom message handlers.
133+
//!
134+
//! For reusable message handling logic, implement [`JrMessageHandler`] and use
135+
//! [`MatchMessage`] or [`MatchMessageFrom`] for type-safe dispatching.
136+
//!
137+
//! # Example
138+
//!
139+
//! ```ignore
140+
//! use std::sync::Arc;
141+
//! use tokio::sync::Mutex;
142+
//! use sacp::{JrMessageHandler, MessageCx, Handled, JrConnectionCx};
143+
//! use sacp::util::MatchMessage;
144+
//!
145+
//! struct MyHandler {
146+
//! state: Arc<Mutex<State>>,
147+
//! }
148+
//!
149+
//! impl JrMessageHandler for MyHandler {
150+
//! type Role = sacp::role::UntypedRole;
151+
//!
152+
//! async fn handle_message(
153+
//! &mut self,
154+
//! message: MessageCx,
155+
//! cx: JrConnectionCx<Self::Role>,
156+
//! ) -> Result<Handled<MessageCx>, sacp::Error> {
157+
//! MatchMessage::new(message)
158+
//! .if_request(async |req: MyRequest, request_cx| {
159+
//! let mut state = self.state.lock().await;
160+
//! state.count += 1;
161+
//! request_cx.respond(MyResponse { count: state.count })
162+
//! }, sacp::if_request!())
163+
//! .await
164+
//! .done()
165+
//! }
166+
//!
167+
//! fn describe_chain(&self) -> impl std::fmt::Debug {
168+
//! "MyHandler"
169+
//! }
170+
//! }
171+
//! ```
172+
//!
173+
//! # When to use `MatchMessage` vs `MatchMessageFrom`
174+
//!
175+
//! - [`MatchMessage`] - Use when you don't need endpoint-aware handling
176+
//! - [`MatchMessageFrom`] - Use in proxies where messages come from different
177+
//! endpoints (`Client` vs `Agent`) and may need different handling
178+
//!
179+
//! [`JrMessageHandler`]: crate::JrMessageHandler
180+
//! [`MatchMessage`]: crate::util::MatchMessage
181+
//! [`MatchMessageFrom`]: crate::util::MatchMessageFrom
182+
}
183+
184+
pub mod connecting_as_client {
185+
//! Pattern: Connecting as a client.
186+
//!
187+
//! To connect to a JSON-RPC server and send requests, use [`with_client`].
188+
//! This gives you a connection context for sending requests while the
189+
//! connection handles incoming messages in the background.
190+
//!
191+
//! # Example
192+
//!
193+
//! ```ignore
194+
//! use sacp::ClientToAgent;
195+
//! use sacp::schema::{InitializeRequest, NewSessionRequest};
196+
//!
197+
//! ClientToAgent::builder()
198+
//! .name("my-client")
199+
//! .on_receive_notification(async |notif: SessionUpdate, cx| {
200+
//! // Handle notifications from the agent
201+
//! println!("Session updated: {:?}", notif);
202+
//! Ok(())
203+
//! }, sacp::on_receive_notification!())
204+
//! .with_client(transport, async |cx| {
205+
//! // Initialize the connection
206+
//! let init_response = cx.send_request(InitializeRequest::make())
207+
//! .block_task()
208+
//! .await?;
209+
//!
210+
//! // Create a session
211+
//! let session = cx.send_request(NewSessionRequest {
212+
//! cwd: std::env::current_dir()?,
213+
//! mcp_servers: vec![],
214+
//! meta: None,
215+
//! })
216+
//! .block_task()
217+
//! .await?;
218+
//!
219+
//! println!("Session created: {:?}", session.session_id);
220+
//! Ok(())
221+
//! })
222+
//! .await?;
223+
//! ```
224+
//!
225+
//! # Note on `block_task`
226+
//!
227+
//! Using [`block_task`] is safe inside `with_client` because the closure runs
228+
//! as a spawned task, not on the event loop. The event loop continues processing
229+
//! messages (including the response you're waiting for) while your task blocks.
230+
//!
231+
//! [`with_client`]: crate::JrConnectionBuilder::with_client
232+
//! [`block_task`]: crate::JrResponse::block_task
233+
}
234+
235+
pub mod global_mcp_server {
236+
//! Pattern: Global MCP server in handler chain.
237+
//!
238+
//! Use this pattern when you want a single MCP server that handles tool calls
239+
//! for all sessions. The server is added to the connection's handler chain and
240+
//! automatically injects itself into every `NewSessionRequest` that passes through.
241+
//!
242+
//! # When to use
243+
//!
244+
//! - The MCP server provides stateless tools (no per-session state needed)
245+
//! - You want the simplest setup with minimal boilerplate
246+
//! - Tools don't need access to session-specific context
247+
//!
248+
//! # Example
249+
//!
250+
//! ```ignore
251+
//! use sacp::mcp_server::McpServer;
252+
//! use sacp::ProxyToConductor;
253+
//!
254+
//! let mcp_server = McpServer::builder("my-tools")
255+
//! .tool_fn("echo", "Echoes the input", async |params: EchoParams, _cx| {
256+
//! Ok(EchoOutput { message: params.message })
257+
//! }, sacp::tool_fn!())
258+
//! .build();
259+
//!
260+
//! ProxyToConductor::builder()
261+
//! .with_mcp_server(mcp_server)
262+
//! .serve(transport)
263+
//! .await?;
264+
//! ```
265+
//!
266+
//! # How it works
267+
//!
268+
//! When you call [`with_mcp_server`], the MCP server is added as a message
269+
//! handler. It:
270+
//!
271+
//! 1. Intercepts `NewSessionRequest` messages and adds its `acp:UUID` URL to the
272+
//! request's `mcp_servers` list
273+
//! 2. Passes the modified request through to the next handler
274+
//! 3. Handles incoming MCP protocol messages (tool calls, etc.) for its URL
275+
//!
276+
//! [`with_mcp_server`]: crate::JrConnectionBuilder::with_mcp_server
277+
}
278+
279+
pub mod per_session_mcp_server {
280+
//! Pattern: Per-session MCP server.
281+
//!
282+
//! Use this pattern when each session needs its own MCP server instance,
283+
//! typically because tools need access to session-specific state or context.
284+
//!
285+
//! # When to use
286+
//!
287+
//! - Tools need access to the session ID or session-specific state
288+
//! - You want to customize the MCP server based on session parameters
289+
//! - Tools need to send notifications back to a specific session
290+
//!
291+
//! # Example
292+
//!
293+
//! ```ignore
294+
//! use sacp::mcp_server::McpServer;
295+
//! use sacp::schema::NewSessionRequest;
296+
//! use sacp::{Agent, Client, ProxyToConductor};
297+
//!
298+
//! ProxyToConductor::builder()
299+
//! .on_receive_request_from(Client, async |request: NewSessionRequest, request_cx, cx| {
300+
//! // Create an MCP server for this session
301+
//! let cwd = request.cwd.clone();
302+
//! let mcp_server = McpServer::builder("session-tools")
303+
//! .tool_fn("get_cwd", "Returns session working directory",
304+
//! async move |_params: (), _cx| {
305+
//! Ok(cwd.display().to_string())
306+
//! }, sacp::tool_fn!())
307+
//! .build();
308+
//!
309+
//! // Build the session with the MCP server attached
310+
//! cx.build_session(request)
311+
//! .with_mcp_server(mcp_server)?
312+
//! .run_session(async |session| {
313+
//! request_cx.respond(session.response().clone())
314+
//! })
315+
//! .await
316+
//! }, sacp::on_receive_request!())
317+
//! .serve(transport)
318+
//! .await?;
319+
//! ```
320+
//!
321+
//! # How it works
322+
//!
323+
//! When you call [`SessionBuilder::with_mcp_server`]:
324+
//!
325+
//! 1. The MCP server is converted into a dynamic handler via `into_dynamic_handler()`
326+
//! 2. The handler is registered for the session's message routing
327+
//! 3. The MCP server's URL is added to the `NewSessionRequest`
328+
//! 4. The handler lives as long as the session (dropped when `run_session` completes)
329+
//!
330+
//! [`SessionBuilder::with_mcp_server`]: crate::SessionBuilder::with_mcp_server
331+
}

0 commit comments

Comments
 (0)