Skip to content

Commit 2b68bcf

Browse files
authored
feat(acp): Add unstable (very experimental!) protocol v2 support (#170)
* feat(acp): Add unstable protocol v2 support Round 2. This time only do the magic for the agent side. * Clippy * Clean up clones * fix(acp): drain failed initialize negotiation state * fix(acp): Use pending version during initialize * fix(acp): clear pending version on init failure * fix(acp): Default builders to v1 protocol mode * fix(acp): Handle outgoing conversion failures locally
1 parent 9a2f4bd commit 2b68bcf

13 files changed

Lines changed: 2192 additions & 47 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ jobs:
4040
config: ./typos.toml
4141

4242
- name: Lint
43-
run: cargo clippy
43+
run: |
44+
cargo clippy
45+
cargo clippy --all-targets --all-features
4446
4547
- name: Build
4648
run: cargo build --all-targets --all-features

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: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub use jsonrpcmsg;
66

77
// Types re-exported from crate root
88
use serde::{Deserialize, Serialize};
9+
use std::any::TypeId;
910
use std::fmt::Debug;
1011
use std::panic::Location;
1112
use std::pin::pin;
@@ -19,6 +20,7 @@ mod dynamic_handler;
1920
pub(crate) mod handlers;
2021
mod incoming_actor;
2122
mod outgoing_actor;
23+
mod protocol_compat;
2224
pub(crate) mod run;
2325
mod task_actor;
2426
mod transport_actor;
@@ -28,6 +30,7 @@ pub use crate::jsonrpc::handlers::NullHandler;
2830
use crate::jsonrpc::handlers::{ChainedHandler, NamedHandler};
2931
use crate::jsonrpc::handlers::{MessageHandler, NotificationHandler, RequestHandler};
3032
use crate::jsonrpc::outgoing_actor::{OutgoingMessageTx, send_raw_message};
33+
use crate::jsonrpc::protocol_compat::{ProtocolCompat, ProtocolMode};
3134
use crate::jsonrpc::run::SpawnedRun;
3235
use crate::jsonrpc::run::{ChainRun, NullRun, RunWithConnectionTo};
3336
use crate::jsonrpc::task_actor::{Task, TaskTx};
@@ -554,6 +557,21 @@ where
554557

555558
/// Responder for background tasks.
556559
responder: Runner,
560+
561+
/// Protocol version mode for the public API and wire compatibility layer.
562+
protocol_mode: ProtocolMode,
563+
}
564+
565+
fn default_protocol_mode<Host: Role>() -> ProtocolMode {
566+
let role = TypeId::of::<Host>();
567+
568+
if role == TypeId::of::<Agent>() {
569+
ProtocolMode::v1_agent()
570+
} else if role == TypeId::of::<Client>() {
571+
ProtocolMode::v1_client()
572+
} else {
573+
ProtocolMode::disabled()
574+
}
557575
}
558576

559577
impl<Host: Role> Builder<Host, NullHandler, NullRun> {
@@ -566,6 +584,7 @@ impl<Host: Role> Builder<Host, NullHandler, NullRun> {
566584
name: None,
567585
handler: NullHandler,
568586
responder: NullRun,
587+
protocol_mode: default_protocol_mode::<Host>(),
569588
}
570589
}
571590
}
@@ -581,6 +600,7 @@ where
581600
name: None,
582601
handler,
583602
responder: NullRun,
603+
protocol_mode: default_protocol_mode::<Host>(),
584604
}
585605
}
586606
}
@@ -597,6 +617,28 @@ impl<
597617
self
598618
}
599619

620+
pub(crate) fn v1_agent(mut self) -> Self {
621+
self.protocol_mode = ProtocolMode::v1_agent();
622+
self
623+
}
624+
625+
pub(crate) fn v1_client(mut self) -> Self {
626+
self.protocol_mode = ProtocolMode::v1_client();
627+
self
628+
}
629+
630+
#[cfg(feature = "unstable_protocol_v2")]
631+
pub(crate) fn v2_agent(mut self) -> Self {
632+
self.protocol_mode = ProtocolMode::v2_agent();
633+
self
634+
}
635+
636+
#[cfg(feature = "unstable_protocol_v2")]
637+
pub(crate) fn v2_client(mut self) -> Self {
638+
self.protocol_mode = ProtocolMode::v2_client();
639+
self
640+
}
641+
600642
/// Merge another [`Builder`] into this one.
601643
///
602644
/// Prefer [`Self::on_receive_request`] or [`Self::on_receive_notification`].
@@ -613,14 +655,22 @@ impl<
613655
impl HandleDispatchFrom<Host::Counterpart>,
614656
impl RunWithConnectionTo<Host::Counterpart>,
615657
> {
658+
let Builder {
659+
name: other_name,
660+
handler: other_handler,
661+
responder: other_responder,
662+
protocol_mode: other_protocol_mode,
663+
host: _,
664+
} = other;
616665
Builder {
617666
host: self.host,
618667
name: self.name,
619668
handler: ChainedHandler::new(
620669
self.handler,
621-
NamedHandler::new(other.name, other.handler),
670+
NamedHandler::new(other_name, other_handler),
622671
),
623-
responder: ChainRun::new(self.responder, other.responder),
672+
responder: ChainRun::new(self.responder, other_responder),
673+
protocol_mode: self.protocol_mode.merge(other_protocol_mode),
624674
}
625675
}
626676

@@ -637,6 +687,7 @@ impl<
637687
name: self.name,
638688
handler: ChainedHandler::new(self.handler, handler),
639689
responder: self.responder,
690+
protocol_mode: self.protocol_mode,
640691
}
641692
}
642693

@@ -653,6 +704,7 @@ impl<
653704
name: self.name,
654705
handler: self.handler,
655706
responder: ChainRun::new(self.responder, responder),
707+
protocol_mode: self.protocol_mode,
656708
}
657709
}
658710

@@ -1173,6 +1225,7 @@ impl<
11731225
handler,
11741226
responder,
11751227
host: me,
1228+
protocol_mode,
11761229
} = self;
11771230

11781231
let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
@@ -1198,6 +1251,7 @@ impl<
11981251
} = transport_channel;
11991252

12001253
let (reply_tx, reply_rx) = mpsc::unbounded();
1254+
let protocol_compat = ProtocolCompat::new(protocol_mode);
12011255

12021256
let future = crate::util::instrument_with_connection_name(name, {
12031257
let connection = connection.clone();
@@ -1211,6 +1265,7 @@ impl<
12111265
outgoing_rx,
12121266
reply_tx.clone(),
12131267
transport_outgoing_tx,
1268+
protocol_compat.clone(),
12141269
),
12151270
// Protocol layer: jsonrpcmsg::Message → handler/reply routing
12161271
incoming_actor::incoming_protocol_actor(
@@ -1220,6 +1275,7 @@ impl<
12201275
dynamic_handler_rx,
12211276
reply_rx,
12221277
handler,
1278+
protocol_compat,
12231279
),
12241280
task_actor::task_actor(new_task_rx, &connection),
12251281
responder.run_with_connection_to(connection.clone()),
@@ -1341,6 +1397,9 @@ enum OutgoingMessage {
13411397
Response {
13421398
id: jsonrpcmsg::Id,
13431399

1400+
/// Method of the incoming request this response completes.
1401+
method: String,
1402+
13441403
response: Result<serde_json::Value, crate::Error>,
13451404
},
13461405

@@ -1907,6 +1966,7 @@ impl Responder<serde_json::Value> {
19071966
/// The response will be serialized to JSON and sent over the wire.
19081967
fn new(message_tx: OutgoingMessageTx, method: String, id: jsonrpcmsg::Id) -> Self {
19091968
let id_clone = id.clone();
1969+
let method_clone = method.clone();
19101970
Self {
19111971
method,
19121972
id,
@@ -1915,6 +1975,7 @@ impl Responder<serde_json::Value> {
19151975
&message_tx,
19161976
OutgoingMessage::Response {
19171977
id: id_clone,
1978+
method: method_clone,
19181979
response,
19191980
},
19201981
)

0 commit comments

Comments
 (0)