Skip to content

Commit ee3a32e

Browse files
committed
feat(acp): Add unstable protocol v2 support
Round 2. This time only do the magic for the agent side.
1 parent bbfb5cb commit ee3a32e

12 files changed

Lines changed: 1790 additions & 24 deletions

File tree

md/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- [Design Overview](./design.md)
88
- [Protocol Reference](./protocol.md)
9+
- [Protocol V2](./protocol-v2.md)
910

1011
# Conductor (agent-client-protocol-conductor)
1112

md/protocol-v2.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Protocol V2
2+
3+
The core SDK can opt into the draft ACP protocol v2 surface with the
4+
`unstable_protocol_v2` crate feature:
5+
6+
```toml
7+
agent-client-protocol = { version = "...", features = ["unstable_protocol_v2"] }
8+
```
9+
10+
This feature is separate from the broad `unstable` feature because protocol v2
11+
is a versioning experiment, not just an unstable method family.
12+
13+
By default, `Client.builder()` and `Agent.builder()` continue to expose the
14+
stable v1 API and advertise protocol v1. To use the v2 API for a connection,
15+
construct the builder with `Client.v2()` or `Agent.v2()`:
16+
17+
```rust
18+
use agent_client_protocol::schema::{ProtocolVersion, v2};
19+
use agent_client_protocol::{Agent, Client};
20+
21+
# async fn run(agent_transport: impl agent_client_protocol::ConnectTo<agent_client_protocol::Client>) -> agent_client_protocol::Result<()> {
22+
Client
23+
.v2()
24+
.connect_with(agent_transport, async |cx| {
25+
let initialize = cx
26+
.send_request(v2::InitializeRequest::new(ProtocolVersion::V1))
27+
.block_task()
28+
.await?;
29+
30+
assert_eq!(initialize.protocol_version, ProtocolVersion::V2);
31+
Ok(())
32+
})
33+
.await?;
34+
# Ok(())
35+
# }
36+
37+
# async fn serve(client_transport: impl agent_client_protocol::ConnectTo<agent_client_protocol::Agent>) -> agent_client_protocol::Result<()> {
38+
Agent
39+
.v2()
40+
.on_receive_request(
41+
async |initialize: v2::InitializeRequest, responder, _cx| {
42+
responder.respond(v2::InitializeResponse::new(initialize.protocol_version))
43+
},
44+
agent_client_protocol::on_receive_request!(),
45+
)
46+
.connect_to(client_transport)
47+
.await?;
48+
# Ok(())
49+
# }
50+
```
51+
52+
When v2 mode is enabled, application code should use types from
53+
`agent_client_protocol::schema::v2`. The flat `agent_client_protocol::schema::*`
54+
exports remain the stable v1 schema. This will likely change as v2 gets closer
55+
to release.
56+
57+
The SDK handles the `initialize` negotiation at the JSON-RPC boundary:
58+
59+
- A v2 client advertises protocol v2 as its latest supported version.
60+
- A v2 client requires a v2 agent. If the agent responds with v1, the
61+
`initialize` request resolves with an error and the caller must explicitly
62+
fall back to a v1 client implementation if that is acceptable.
63+
- A v2 agent responds with v2 when the client supports it, or v1 when the client
64+
only supports v1. Agent handlers still receive v2 schema types; the SDK tracks
65+
the negotiated wire version separately and adapts supported behavior at the
66+
transport boundary.
67+
- If the agent responds with any other unsupported version, the request resolves
68+
with an error so the client can close the connection.
69+
- After initialization, the SDK converts supported messages and responses between
70+
the local API version and the negotiated wire version.
71+
72+
That means an agent can be implemented against v2 request and response types
73+
while still serving v1 clients. The goal is for agent-side v1 compatibility to
74+
live in the SDK wherever it can be represented as protocol adaptation. Clients
75+
should opt into v2 separately and should not assume v2 behavior from v1 agents.

src/agent-client-protocol/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ unstable_session_delete = ["agent-client-protocol-schema/unstable_session_delete
3737
unstable_session_fork = ["agent-client-protocol-schema/unstable_session_fork"]
3838
unstable_session_model = ["agent-client-protocol-schema/unstable_session_model"]
3939
unstable_session_usage = ["agent-client-protocol-schema/unstable_session_usage"]
40+
unstable_protocol_v2 = ["agent-client-protocol-schema/unstable_protocol_v2"]
4041

4142
[dependencies]
4243
agent-client-protocol-schema.workspace = true

src/agent-client-protocol/src/jsonrpc.rs

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ mod dynamic_handler;
1919
pub(crate) mod handlers;
2020
mod incoming_actor;
2121
mod outgoing_actor;
22+
mod protocol_compat;
2223
pub(crate) mod run;
2324
mod task_actor;
2425
mod transport_actor;
@@ -28,6 +29,7 @@ pub use crate::jsonrpc::handlers::NullHandler;
2829
use crate::jsonrpc::handlers::{ChainedHandler, NamedHandler};
2930
use crate::jsonrpc::handlers::{MessageHandler, NotificationHandler, RequestHandler};
3031
use crate::jsonrpc::outgoing_actor::{OutgoingMessageTx, send_raw_message};
32+
use crate::jsonrpc::protocol_compat::{ProtocolCompat, ProtocolMode};
3133
use crate::jsonrpc::run::SpawnedRun;
3234
use crate::jsonrpc::run::{ChainRun, NullRun, RunWithConnectionTo};
3335
use crate::jsonrpc::task_actor::{Task, TaskTx};
@@ -554,6 +556,9 @@ where
554556

555557
/// Responder for background tasks.
556558
responder: Runner,
559+
560+
/// Protocol version mode for the public API and wire compatibility layer.
561+
protocol_mode: ProtocolMode,
557562
}
558563

559564
impl<Host: Role> Builder<Host, NullHandler, NullRun> {
@@ -566,6 +571,7 @@ impl<Host: Role> Builder<Host, NullHandler, NullRun> {
566571
name: None,
567572
handler: NullHandler,
568573
responder: NullRun,
574+
protocol_mode: ProtocolMode::disabled(),
569575
}
570576
}
571577
}
@@ -581,6 +587,7 @@ where
581587
name: None,
582588
handler,
583589
responder: NullRun,
590+
protocol_mode: ProtocolMode::disabled(),
584591
}
585592
}
586593
}
@@ -597,6 +604,28 @@ impl<
597604
self
598605
}
599606

607+
pub(crate) fn v1_agent(mut self) -> Self {
608+
self.protocol_mode = ProtocolMode::v1_agent();
609+
self
610+
}
611+
612+
pub(crate) fn v1_client(mut self) -> Self {
613+
self.protocol_mode = ProtocolMode::v1_client();
614+
self
615+
}
616+
617+
#[cfg(feature = "unstable_protocol_v2")]
618+
pub(crate) fn v2_agent(mut self) -> Self {
619+
self.protocol_mode = ProtocolMode::v2_agent();
620+
self
621+
}
622+
623+
#[cfg(feature = "unstable_protocol_v2")]
624+
pub(crate) fn v2_client(mut self) -> Self {
625+
self.protocol_mode = ProtocolMode::v2_client();
626+
self
627+
}
628+
600629
/// Merge another [`Builder`] into this one.
601630
///
602631
/// Prefer [`Self::on_receive_request`] or [`Self::on_receive_notification`].
@@ -613,14 +642,22 @@ impl<
613642
impl HandleDispatchFrom<Host::Counterpart>,
614643
impl RunWithConnectionTo<Host::Counterpart>,
615644
> {
645+
let Builder {
646+
name: other_name,
647+
handler: other_handler,
648+
responder: other_responder,
649+
protocol_mode: other_protocol_mode,
650+
host: _,
651+
} = other;
616652
Builder {
617653
host: self.host,
618654
name: self.name,
619655
handler: ChainedHandler::new(
620656
self.handler,
621-
NamedHandler::new(other.name, other.handler),
657+
NamedHandler::new(other_name, other_handler),
622658
),
623-
responder: ChainRun::new(self.responder, other.responder),
659+
responder: ChainRun::new(self.responder, other_responder),
660+
protocol_mode: self.protocol_mode.merge(other_protocol_mode),
624661
}
625662
}
626663

@@ -637,6 +674,7 @@ impl<
637674
name: self.name,
638675
handler: ChainedHandler::new(self.handler, handler),
639676
responder: self.responder,
677+
protocol_mode: self.protocol_mode,
640678
}
641679
}
642680

@@ -653,6 +691,7 @@ impl<
653691
name: self.name,
654692
handler: self.handler,
655693
responder: ChainRun::new(self.responder, responder),
694+
protocol_mode: self.protocol_mode,
656695
}
657696
}
658697

@@ -1173,6 +1212,7 @@ impl<
11731212
handler,
11741213
responder,
11751214
host: me,
1215+
protocol_mode,
11761216
} = self;
11771217

11781218
let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
@@ -1198,6 +1238,7 @@ impl<
11981238
} = transport_channel;
11991239

12001240
let (reply_tx, reply_rx) = mpsc::unbounded();
1241+
let protocol_compat = ProtocolCompat::new(protocol_mode);
12011242

12021243
let future = crate::util::instrument_with_connection_name(name, {
12031244
let connection = connection.clone();
@@ -1211,6 +1252,7 @@ impl<
12111252
outgoing_rx,
12121253
reply_tx.clone(),
12131254
transport_outgoing_tx,
1255+
protocol_compat.clone(),
12141256
),
12151257
// Protocol layer: jsonrpcmsg::Message → handler/reply routing
12161258
incoming_actor::incoming_protocol_actor(
@@ -1220,6 +1262,7 @@ impl<
12201262
dynamic_handler_rx,
12211263
reply_rx,
12221264
handler,
1265+
protocol_compat,
12231266
),
12241267
task_actor::task_actor(new_task_rx, &connection),
12251268
responder.run_with_connection_to(connection.clone()),
@@ -1341,6 +1384,9 @@ enum OutgoingMessage {
13411384
Response {
13421385
id: jsonrpcmsg::Id,
13431386

1387+
/// Method of the incoming request this response completes.
1388+
method: String,
1389+
13441390
response: Result<serde_json::Value, crate::Error>,
13451391
},
13461392

@@ -1907,6 +1953,7 @@ impl Responder<serde_json::Value> {
19071953
/// The response will be serialized to JSON and sent over the wire.
19081954
fn new(message_tx: OutgoingMessageTx, method: String, id: jsonrpcmsg::Id) -> Self {
19091955
let id_clone = id.clone();
1956+
let method_clone = method.clone();
19101957
Self {
19111958
method,
19121959
id,
@@ -1915,6 +1962,7 @@ impl Responder<serde_json::Value> {
19151962
&message_tx,
19161963
OutgoingMessage::Response {
19171964
id: id_clone,
1965+
method: method_clone,
19181966
response,
19191967
},
19201968
)

0 commit comments

Comments
 (0)