Skip to content

Commit 0169f1e

Browse files
authored
Merge pull request #91 from nikomatsakis/message-handler-send
feat(sacp)!: require Send for JrMessageHandler with boxing witness ma…
2 parents 902e5fe + 7aa9b3a commit 0169f1e

33 files changed

+1121
-593
lines changed

md/building-agent.md

Lines changed: 223 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -4,140 +4,273 @@ This chapter explains how to build an ACP agent using the `sacp` crate.
44

55
## Overview
66

7-
An agent is the final component in a SACP proxy chain. It provides the base AI model behavior and doesn't need awareness of SACP - it's just a standard ACP agent.
7+
An agent is the component that provides AI model behavior in an ACP system. It receives prompts from clients (editors like Zed or Claude Code) and returns responses. Agents can also work as the final component in a conductor proxy chain.
88

9-
However, the `sacp` crate provides useful types and utilities for building ACP agents.
9+
## Quick Start
1010

11-
## Core Types
11+
Here's a minimal agent that handles initialization:
1212

13-
The `sacp` crate provides Rust types for ACP protocol messages:
13+
```rust
14+
use sacp::AgentToClient;
15+
use sacp::schema::{AgentCapabilities, InitializeRequest, InitializeResponse};
16+
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
17+
18+
#[tokio::main]
19+
async fn main() -> Result<(), sacp::Error> {
20+
AgentToClient::builder()
21+
.name("my-agent")
22+
.on_receive_request(
23+
async move |req: InitializeRequest, request_cx, _cx| {
24+
request_cx.respond(InitializeResponse {
25+
protocol_version: req.protocol_version,
26+
agent_capabilities: AgentCapabilities::default(),
27+
auth_methods: Default::default(),
28+
agent_info: Default::default(),
29+
meta: Default::default(),
30+
})
31+
},
32+
sacp::on_receive_request!(),
33+
)
34+
.serve(sacp::ByteStreams::new(
35+
tokio::io::stdout().compat_write(),
36+
tokio::io::stdin().compat(),
37+
))
38+
.await
39+
}
40+
```
41+
42+
## Core Concepts
43+
44+
### The Builder Pattern
45+
46+
Agents are built using `AgentToClient::builder()`. You register handlers for different message types, then call `.serve()` with a transport:
1447

1548
```rust
16-
use sacp::{
17-
InitializeRequest, InitializeResponse,
18-
PromptRequest, PromptResponse,
19-
// ... other ACP types
20-
};
49+
AgentToClient::builder()
50+
.name("my-agent") // For logging/debugging
51+
.on_receive_request(handler1, macro1) // Handle specific request type
52+
.on_receive_request(handler2, macro2) // Chain multiple handlers
53+
.on_receive_notification(handler3, macro3) // Handle notifications
54+
.serve(transport)
55+
.await
2156
```
2257

23-
These types handle:
24-
- Serialization/deserialization
25-
- Protocol validation
26-
- Type safety for message handling
58+
### Request Handlers
2759

28-
## JSON-RPC Foundation
60+
Request handlers receive three parameters:
2961

30-
The `sacp` crate includes a JSON-RPC layer that handles:
62+
1. **The request** - Typed by what you're handling (e.g., `InitializeRequest`, `PromptRequest`)
63+
2. **The request context** (`JrRequestCx`) - Used to send the response
64+
3. **The connection context** (`JrConnectionCx`) - Used for sending notifications or spawning tasks
3165

32-
- Message framing over stdio or other transports
33-
- Request/response correlation
34-
- Notification handling
35-
- Error propagation
66+
```rust
67+
.on_receive_request(
68+
async move |request: PromptRequest, request_cx, cx| {
69+
// Process the request...
70+
71+
// Send the response
72+
request_cx.respond(PromptResponse {
73+
stop_reason: StopReason::EndTurn,
74+
meta: None,
75+
})
76+
},
77+
sacp::on_receive_request!(),
78+
)
79+
```
80+
81+
### The Witness Macro
82+
83+
Every handler registration requires a "witness" macro as the final parameter. This is a workaround for missing Rust language features (return-type notation). Always use the matching macro:
84+
85+
- `.on_receive_request(..., sacp::on_receive_request!())`
86+
- `.on_receive_notification(..., sacp::on_receive_notification!())`
87+
- `.on_receive_message(..., sacp::on_receive_message!())`
88+
89+
### Sending Notifications
90+
91+
Use the connection context to send notifications back to the client:
3692

3793
```rust
38-
use sacp::{JsonRpcConnection, JsonRpcHandler};
94+
.on_receive_request(
95+
async move |request: PromptRequest, request_cx, cx| {
96+
// Send streaming content
97+
cx.send_notification(SessionNotification {
98+
session_id: request.session_id.clone(),
99+
update: SessionUpdate::AgentMessageChunk(ContentChunk {
100+
content: "Hello!".into(),
101+
meta: None,
102+
}),
103+
meta: None,
104+
})?;
105+
106+
// Complete the request
107+
request_cx.respond(PromptResponse {
108+
stop_reason: StopReason::EndTurn,
109+
meta: None,
110+
})
111+
},
112+
sacp::on_receive_request!(),
113+
)
114+
```
115+
116+
### Spawning Background Work
39117

40-
// Create a connection over stdio
41-
let connection = JsonRpcConnection::new(stdin(), stdout(), my_handler);
118+
Don't block the message loop with long-running operations. Use `cx.spawn()` to run work in the background:
42119

43-
// Run the message loop
44-
connection.run().await?;
120+
```rust
121+
.on_receive_request(
122+
async move |request: PromptRequest, request_cx, cx| {
123+
let cx_clone = cx.clone();
124+
cx.spawn(async move {
125+
// Long-running AI inference...
126+
let response = generate_response(&request).await;
127+
128+
// Send notification with result
129+
cx_clone.send_notification(SessionNotification { /* ... */ })?;
130+
131+
// Complete the request
132+
request_cx.respond(PromptResponse {
133+
stop_reason: StopReason::EndTurn,
134+
meta: None,
135+
})
136+
})
137+
},
138+
sacp::on_receive_request!(),
139+
)
45140
```
46141

47-
## Handler Pattern
142+
## Building a Reusable Agent Component
48143

49-
Implement `JsonRpcHandler` to process ACP messages:
144+
For agents that can be composed into larger systems (e.g., with the conductor), implement the `Component` trait:
50145

51146
```rust
52-
use sacp::{JsonRpcHandler, MessageAndCx, Handled};
147+
use sacp::{AgentToClient, Component};
148+
use sacp::schema::*;
53149

54-
struct MyAgent {
55-
// Agent state
150+
pub struct MyAgent {
151+
config: AgentConfig,
56152
}
57153

58-
impl JsonRpcHandler for MyAgent {
59-
async fn handle_message(&mut self, message: MessageAndCx) -> Result<Handled> {
60-
match message {
61-
MessageAndCx::Request(req, cx) => {
62-
match req {
63-
AcpRequest::Initialize(init) => {
64-
// Handle initialization
65-
let response = InitializeResponse {
66-
protocolVersion: "0.7.0",
67-
serverInfo: ServerInfo { /* ... */ },
68-
capabilities: Capabilities { /* ... */ },
69-
};
70-
cx.respond(response)?;
71-
Ok(Handled::FullyHandled)
72-
}
73-
AcpRequest::Prompt(prompt) => {
74-
// Call your AI model
75-
let response = self.generate_response(prompt).await?;
76-
cx.respond(response)?;
77-
Ok(Handled::FullyHandled)
154+
impl Component for MyAgent {
155+
async fn serve(self, client: impl Component) -> Result<(), sacp::Error> {
156+
AgentToClient::builder()
157+
.name("my-agent")
158+
.on_receive_request(
159+
async |req: InitializeRequest, request_cx, _cx| {
160+
request_cx.respond(InitializeResponse {
161+
protocol_version: req.protocol_version,
162+
agent_capabilities: AgentCapabilities::default(),
163+
auth_methods: Default::default(),
164+
agent_info: Default::default(),
165+
meta: Default::default(),
166+
})
167+
},
168+
sacp::on_receive_request!(),
169+
)
170+
.on_receive_request(
171+
{
172+
let agent = self.clone();
173+
async move |req: PromptRequest, request_cx, cx| {
174+
agent.handle_prompt(req, request_cx, cx).await
78175
}
79-
// ... other message types
80-
}
81-
}
82-
MessageAndCx::Notification(notif, cx) => {
83-
// Handle notifications
84-
}
85-
}
176+
},
177+
sacp::on_receive_request!(),
178+
)
179+
.connect_to(client)?
180+
.serve()
181+
.await
86182
}
87183
}
88184
```
89185

90-
## Working with Proxies
91-
92-
Your agent doesn't need to know about SACP proxies. However, there are some optional capabilities that improve proxy integration:
186+
Note the difference: `.serve(transport)` for standalone agents vs `.connect_to(client)?.serve()` for composable components.
93187

94-
### MCP-over-ACP Support
188+
## Handling Multiple Request Types
95189

96-
If your agent can handle MCP servers declared with `acp:UUID` URLs, advertise the capability:
190+
Chain multiple `.on_receive_request()` calls to handle different message types. Handlers are tried in order until one matches:
97191

98192
```rust
99-
InitializeResponse {
100-
// ...
101-
_meta: json!({
102-
"mcp_acp_transport": true
103-
}),
104-
}
193+
AgentToClient::builder()
194+
.name("my-agent")
195+
.on_receive_request(
196+
async |req: InitializeRequest, request_cx, _cx| {
197+
request_cx.respond(InitializeResponse { /* ... */ })
198+
},
199+
sacp::on_receive_request!(),
200+
)
201+
.on_receive_request(
202+
async |req: NewSessionRequest, request_cx, _cx| {
203+
request_cx.respond(NewSessionResponse { /* ... */ })
204+
},
205+
sacp::on_receive_request!(),
206+
)
207+
.on_receive_request(
208+
async |req: PromptRequest, request_cx, cx| {
209+
// Handle prompts...
210+
request_cx.respond(PromptResponse { /* ... */ })
211+
},
212+
sacp::on_receive_request!(),
213+
)
214+
.serve(transport)
215+
.await
105216
```
106217

107-
This allows the conductor to skip bridging and pass MCP declarations through directly.
218+
## Catch-All Handler
108219

109-
Without this capability, the conductor will automatically bridge MCP-over-ACP to stdio for you.
220+
Use `.on_receive_message()` to handle any message not caught by specific handlers:
110221

111-
## Testing
222+
```rust
223+
AgentToClient::builder()
224+
.name("my-agent")
225+
.on_receive_request(/* ... specific handlers ... */)
226+
.on_receive_message(
227+
async move |message: MessageCx, cx| {
228+
// Return an error for unhandled messages
229+
message.respond_with_error(
230+
sacp::util::internal_error("Unhandled message type"),
231+
cx,
232+
)
233+
},
234+
sacp::on_receive_message!(),
235+
)
236+
.serve(transport)
237+
.await
238+
```
239+
240+
## Protocol Types
112241

113-
The `sacp` crate provides test utilities:
242+
The `sacp::schema` module provides all ACP protocol types:
114243

115244
```rust
116-
#[cfg(test)]
117-
mod tests {
118-
use sacp::testing::*;
245+
use sacp::schema::{
246+
// Initialization
247+
InitializeRequest, InitializeResponse,
248+
AgentCapabilities,
119249

120-
#[test]
121-
fn test_prompt_handling() {
122-
let agent = MyAgent::new();
123-
let response = agent.handle_prompt(test_prompt()).await?;
124-
assert_eq!(response.role, Role::Assistant);
125-
}
126-
}
250+
// Sessions
251+
NewSessionRequest, NewSessionResponse,
252+
LoadSessionRequest, LoadSessionResponse,
253+
SessionId,
254+
255+
// Prompts
256+
PromptRequest, PromptResponse,
257+
ContentBlock, ContentChunk,
258+
StopReason,
259+
260+
// Notifications
261+
SessionNotification, SessionUpdate,
262+
263+
// MCP
264+
McpServer,
265+
};
127266
```
128267

129-
## Standard ACP Implementation
130-
131-
Remember: An agent built with `sacp` is a standard ACP agent. It will work:
132-
133-
- Directly with ACP editors (Zed, Claude Code, etc.)
134-
- As the final component in a SACP proxy chain
135-
- With any ACP-compatible tooling
268+
## Complete Example
136269

137-
The `sacp` crate just provides convenient Rust types and infrastructure.
270+
See the [elizacp](https://github.com/symposium-dev/symposium-acp/tree/main/src/elizacp) crate for a complete working agent implementation with session management and MCP tool support.
138271

139272
## Next Steps
140273

141274
- See [Protocol Reference](./protocol.md) for message format details
142-
- Read the `sacp` crate documentation for API details
275+
- Read the `sacp` crate rustdoc for full API documentation
143276
- Check the [ACP specification](https://agentclientprotocol.com/) for protocol details

0 commit comments

Comments
 (0)