Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 75 additions & 9 deletions md/migration-v5.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ derives, `UntypedMessage`, `Error`, `ErrorCode`, `Result`, `Protocol` with
`SentRequest`, `PreparedRequest`, `Dispatch` and `Builder`, defaulting to
`Raw`), the version-neutral `ConnectionTo`, and the routers that serve both
versions (`AgentProtocolRouter`, `ProxyProtocolRouter`,
`ClientProtocolConnector`).
`ClientProtocolConnector`). With `unstable_protocol_v2` it also has
`InitializationVersionMismatch` and `SelectedInitializeResponse`, which
`v2::InitializeOutcome::LowerVersion` uses to report a lower version the
peer selected.

| Name | `agent_client_protocol::v1` | `agent_client_protocol::v2` |
| --- | --- | --- |
Expand All @@ -42,7 +45,7 @@ versions (`AgentProtocolRouter`, `ProxyProtocolRouter`,
| `ActiveSession` | the session handle with its update stream | the command-only session handle |
| `ForkSessionBuilder` | - | `fork_session*` (`unstable_session_fork`) |
| `LoadSessionBuilder`, `SessionMessage`, `Blocking`, `NonBlocking`, `SessionBlockState` | v1 session helpers | - |
| `InitializeOutcome`, `InitializationVersionMismatch`, `SelectedInitializeResponse` | - | the result of `send_initialize` |
| `InitializeOutcome` | - | the result of `send_initialize` |
| `schema` | `schema::v1` | `schema::v2` |

### `v1::ConnectionTo` is its own type
Expand All @@ -66,7 +69,7 @@ It no longer has the v1 session helpers. Convert explicitly between the two;
use agent_client_protocol::{ConnectionTo, V1, v1};

// raw -> v1, for example in a custom runner that opens a v1 session
let v1_cx: v1::ConnectionTo<Agent> = raw_cx.into_protocol::<V1>();
let v1_cx: v1::ConnectionTo<Agent> = raw_cx.into_protocol::<V1>()?;
v1_cx.build_session_cwd()?.block_task().start_session().await?;

// v1 -> raw, for example to drive a `RunWithConnectionTo` runner
Expand All @@ -89,6 +92,36 @@ fn send(cx: &ConnectionTo<Agent>) -> SentRequest<PromptResponse> { ... }
Where code compared a v1 request's `id()` with IDs from the raw layer, use
`raw_id()`.

### Converting, spawning and cancelling on connections

- `ConnectionTo::into_protocol::<P>()` returns
`Result<P::Connection<_>, Error>`. It fails with an Internal error
(`-32603`) when the connection's builder guards another version, for
example `into_protocol::<V2>()` on a connection from `Client.v1()`, which
used to succeed and then send v2 payloads over the v1 wire. Connections from
unguarded builders such as `Builder::new` convert to every version. Add `?`
to existing calls. The new `ConnectionTo::protocol_version()` returns the
guarded version, or `None` for an unguarded connection.
- `ConnectionTo::spawn_connection` returns the connection type the child
builder's callbacks receive, like `v1::ConnectionTo::spawn_connection` and
`v2::ConnectionTo::spawn_connection` already did. It replaces
`ConnectionTo::spawn_connection_with_context`, which is removed. Spawning
`Client.v1()` now returns a `v1::ConnectionTo<_>`; call `.into_raw()` where
the raw connection is wanted. A raw builder still returns the raw
`ConnectionTo`. Turbofish calls name both parameters:
`spawn_connection::<UntypedRole, _>(...)`.
- `v1::ConnectionTo::send_cancel_request(_to)` takes
`impl Into<schema::v1::RequestId>` and `v2::ConnectionTo::send_cancel_request(_to)`
takes `impl Into<schema::v2::RequestId>`, the type their requests' `id()`
returns. The v1 change is source-compatible: `RawJsonRpcId` converts into
`schema::v1::RequestId`, so v1 calls that pass one still compile. V2 calls
that pass a `RawJsonRpcId` must pass a `schema::v2::RequestId` instead, so
the v2 methods can no longer send `{"requestId": null}`, which v2 forbids.
The raw `ConnectionTo` methods still take any `RawJsonRpcId`.
- `Builder::with_v2_protocol_guard` (`unstable_protocol_v2`) is removed. A
builder that needs the v2 guard is `Client.v2()` or `Agent.v2()`; code that
needs raw handles in its callbacks converts them with `into_raw()`.

### Every session setup method returns `SessionSetup`

`v1::SessionSetup` and `v2::SessionSetup` replace `v1::OpenedSession`
Expand Down Expand Up @@ -213,8 +246,9 @@ their path.
| `OpenedV2Session` | `v2::SessionSetup` |
| `V2Session` | `v2::ActiveSession` |
| `InitializeOutcome` | `v2::InitializeOutcome` |
| `InitializationVersionMismatch` | `v2::InitializationVersionMismatch` |
| `SelectedInitializeResponse` | `v2::SelectedInitializeResponse` |
| `InitializationVersionMismatch` | unchanged, at the crate root |
| `InitializationVersionMismatch::selected` (field) | `InitializationVersionMismatch::selected()`, the version of `response` |
| `SelectedInitializeResponse` | unchanged, at the crate root |
| `SessionBuilder` | `v1::SessionBuilder` |
| `ActiveSession` | `v1::ActiveSession` |
| `RestoredSession` | `v1::SessionSetup` |
Expand Down Expand Up @@ -710,8 +744,7 @@ async |message: Dispatch<PromptRequest, SessionNotification>, cx| { ... }

`Client.v1()`, `Agent.v1()` and `Proxy.v1()` return `v1::Builder<_>`
(`Builder<_, _, _, _, V1>`).
`Builder::new`, other roles' builders, and `with_v2_protocol_guard()`
(which returns a `Raw` builder) use `Raw`. The v1 session helpers that take
`Builder::new` and other roles' builders use `Raw`. The v1 session helpers that take
a responder, such as `v1::SessionBuilder::on_proxy_session_start`, take a
`v1::Responder<_>`.

Expand Down Expand Up @@ -747,8 +780,9 @@ fn send(session: &ActiveSession<Agent>) -> SentRequest<v2::PromptResponse> { ...

### Getting a typed handle from the raw layer

`Responder`, `ResponseRouter` and `Dispatch` with the `Raw` marker convert
to a typed marker with `into_protocol`. Only the ID's type changes. It fails
`Responder`, `ResponseRouter`, `SentRequest`, `PreparedRequest` and
`Dispatch` with the `Raw` marker convert to a typed marker with
`into_protocol`. Only the ID's type changes. It fails
only for a `null` ID on `V2`, with the Invalid Request error (`-32600`) a v2
connection sends for such a request; a request received on a v2 connection
never has one. Any handle converts back with the infallible `into_raw`:
Expand Down Expand Up @@ -798,6 +832,38 @@ callbacks receive (`ConnectionTo` for `Raw`, `v1::ConnectionTo` for `V1`,
`v2::ConnectionTo` for `V2`), so code generic over a builder's context changes
`Context: ConnectionContext` to `P: Protocol`.

Generic code can turn a `P::Connection<Counterpart>` back into the
version-neutral `ConnectionTo` with `.into()`: `v1::ConnectionTo` and
`v2::ConnectionTo` implement `From` for it, and `Protocol::Connection`
requires `Into<ConnectionTo<Counterpart>>`. Their own methods, such as the v1
session helpers, are only available on the concrete types.

## `Channel`, `JsonRpcMessage::parse_message` and `JsonRpcResponse::from_json`

- `Channel` is `#[non_exhaustive]`. Build one with `Channel::new(rx, tx)`
(or `Channel::duplex()`) and take it apart with `into_parts()` or a pattern
with `..`:

```rust,ignore
// Before
let channel = Channel { rx, tx };
let Channel { rx, tx } = channel;

// After
let channel = Channel::new(rx, tx);
let Channel { rx, tx, .. } = channel;
```

- `JsonRpcMessage::parse_message` takes `params: &serde_json::Value` instead
of `&impl Serialize`. Update hand-written implementations; derived ones
change with the derive macros. Callers holding raw
`Option<RawJsonRpcParams>` pass
`&params.map_or(serde_json::Value::Null, RawJsonRpcParams::into_value)`.
- `JsonRpcResponse::from_value` is renamed to `from_json`, to pair with
`into_json`. Rename it in hand-written implementations and calls such as
`PromptResponse::from_value(method, value)`; derived implementations change
with the derive macros.

## Schema enums and SDK helpers

### v1 extension methods keep their `_` prefix
Expand Down
27 changes: 12 additions & 15 deletions md/protocol-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,21 +115,16 @@ the same low-level `HandleDispatchFrom` interface and therefore also receive
`ConnectionTo<_>`. Raw code converts explicitly: `connection.into_protocol::<V1>()`
returns a `v1::ConnectionTo` (and `into_protocol::<V2>()` a `v2::ConnectionTo`),
and either versioned connection's `into_raw()` returns the version-neutral one.
`into_protocol` fails with `-32603` when the connection's builder guards a
different version, so a connection from `Client.v1()` never becomes a
`v2::ConnectionTo`. `ConnectionTo::protocol_version()` reports the guarded
version, or `None` for an unguarded `Builder::new` connection.

Nested connections preserve the stable `ConnectionTo` API while still typing
the child implementation's callbacks. On a raw `ConnectionTo<_>`,
`spawn_connection(Client.v2(), transport)` returns a raw `ConnectionTo<_>`, and
callbacks installed on that v2 child builder still receive
`v2::ConnectionTo<_>`. Existing `spawn_connection::<Role>` calls therefore remain
source-compatible.

When a raw parent also needs a typed handle to the child,
`ConnectionTo::spawn_connection_with_context` returns the context selected by
the child builder. `v1::ConnectionTo::spawn_connection` and
`v2::ConnectionTo::spawn_connection` likewise follow the child builder
naturally, so spawning `Client.v2()` through an already-typed v2 connection
returns another `v2::ConnectionTo<_>`, and spawning `Client.v1()` through any
typed connection returns a `v1::ConnectionTo<_>`.
`spawn_connection` returns the connection type the child builder's callbacks
receive, on the raw `ConnectionTo` and on both versioned connections:
spawning `Client.v2()` returns a `v2::ConnectionTo<_>`, spawning
`Client.v1()` a `v1::ConnectionTo<_>`, and spawning a raw `Builder::new`
builder a raw `ConnectionTo<_>`.

A complete client installs update and interactive-request handlers before
connecting. After `session/prompt` is accepted, it must keep the connection
Expand Down Expand Up @@ -443,7 +438,9 @@ The SDK handles the `initialize` negotiation at the JSON-RPC boundary:
`send_initialize` to learn how the agent negotiated: it resolves with
`v2::InitializeOutcome::Initialized` for a v2 selection, or
`v2::InitializeOutcome::LowerVersion` with the agent's validated response for
the selected version (`v2::SelectedInitializeResponse::V1`). After a lower
the selected version, an `InitializationVersionMismatch` whose `response` is a
`SelectedInitializeResponse::V1`. Both types are at the crate root because
they are not tied to one protocol version. After a lower
selection the connection stays uninitialized, and the caller can explicitly
fall back to a v1 client implementation if that is acceptable. Errors
returned by the agent, including `-32022`, malformed responses, and transport
Expand Down
6 changes: 3 additions & 3 deletions src/agent-client-protocol-derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ pub fn derive_json_rpc_request(input: TokenStream) -> TokenStream {

fn parse_message(
#method_arg: &str,
#params_arg: &impl #krate::__private::serde::Serialize,
#params_arg: &#krate::__private::serde_json::Value,
) -> ::core::result::Result<Self, #krate::Error> {
if #method_arg != #method {
return ::core::result::Result::Err(#krate::Error::method_not_found());
Expand Down Expand Up @@ -163,7 +163,7 @@ pub fn derive_json_rpc_notification(input: TokenStream) -> TokenStream {

fn parse_message(
#method_arg: &str,
#params_arg: &impl #krate::__private::serde::Serialize,
#params_arg: &#krate::__private::serde_json::Value,
) -> ::core::result::Result<Self, #krate::Error> {
if #method_arg != #method {
return ::core::result::Result::Err(#krate::Error::method_not_found());
Expand Down Expand Up @@ -215,7 +215,7 @@ pub fn derive_json_rpc_response_payload(input: TokenStream) -> TokenStream {
#krate::__private::serde_json::to_value(self).map_err(#krate::Error::into_internal_error)
}

fn from_value(#method_arg: &str, #value_arg: #krate::__private::serde_json::Value) -> ::core::result::Result<Self, #krate::Error> {
fn from_json(#method_arg: &str, #value_arg: #krate::__private::serde_json::Value) -> ::core::result::Result<Self, #krate::Error> {
#krate::__private::json_cast(#value_arg)
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/agent-client-protocol-http/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ async fn run_with_finish(
let Channel {
rx: outgoing,
tx: incoming,
..
} = channel;
let mut outgoing = finishable_outgoing(outgoing, finish);
let (sse_event_tx, mut sse_event_rx) = mpsc::unbounded::<SseMessage>();
Expand Down Expand Up @@ -1530,6 +1531,7 @@ where
let Channel {
rx: outgoing,
tx: incoming,
..
} = channel;
let mut outgoing = finishable_outgoing(outgoing, finish);
let writer = async move {
Expand Down Expand Up @@ -3700,6 +3702,7 @@ mod tests {
let Channel {
tx: outgoing,
rx: incoming,
..
} = caller;
drop(incoming);

Expand Down Expand Up @@ -3746,6 +3749,7 @@ mod tests {
let Channel {
tx: outgoing,
rx: incoming,
..
} = caller;
drop(incoming);
outgoing
Expand Down
1 change: 1 addition & 0 deletions src/agent-client-protocol-http/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ impl ConnectionRegistry {
let Channel {
rx: mut agent_rx,
tx: mut agent_tx,
..
} = channel;
let inbound = async move {
while let Some(msg) = inbound_rx.recv().await {
Expand Down
4 changes: 2 additions & 2 deletions src/agent-client-protocol-http/src/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,7 @@ mod tests {
let forwarded = self.forwarded.clone();
let future = ConnectionDriver::new(async move {
let Channel {
rx: mut incoming,
tx: _,
rx: mut incoming, ..
} = agent;
while let Some(frame) = incoming.next().await {
let TransportFrame::Single(message) = frame else {
Expand Down Expand Up @@ -522,6 +521,7 @@ mod tests {
let Channel {
rx: mut incoming,
tx: _outgoing,
..
} = agent;
drop(incoming.next().await);
std::future::pending::<agent_client_protocol::Result<()>>().await
Expand Down
1 change: 1 addition & 0 deletions src/agent-client-protocol-http/src/websocket_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ mod tests {
let Channel {
rx: mut incoming,
tx: outgoing,
..
} = agent;
while let Some(frame) = incoming.next().await {
match frame {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ impl PolyfillProtocol {
/// Validate against the negotiated ACP version before projecting onto HTTP.
pub(super) fn message_response(self, value: Value) -> Result<NativeMcpOutcome, Error> {
match self {
Self::V1 => match v1::MessageMcpResponse::from_value("mcp/message", value)? {
Self::V1 => match v1::MessageMcpResponse::from_json("mcp/message", value)? {
v1::MessageMcpResponse::Result { result, .. } => {
Ok(NativeMcpOutcome::Result(result))
}
Expand All @@ -38,7 +38,7 @@ impl PolyfillProtocol {
_ => Err(Error::invalid_request().data("unsupported MCP outcome")),
},
#[cfg(feature = "unstable_protocol_v2")]
Self::V2 => match v2::MessageMcpResponse::from_value("mcp/message", value)? {
Self::V2 => match v2::MessageMcpResponse::from_json("mcp/message", value)? {
v2::MessageMcpResponse::Result { result, .. } => {
Ok(NativeMcpOutcome::Result(result))
}
Expand Down Expand Up @@ -100,7 +100,7 @@ impl PolyfillProtocol {
) -> Result<DownstreamMcpMode, Error> {
let mode = match self {
Self::V1 => {
let parsed = agent_client_protocol::schema::v1::InitializeResponse::from_value(
let parsed = agent_client_protocol::schema::v1::InitializeResponse::from_json(
"initialize",
response.clone(),
)?;
Expand All @@ -111,7 +111,7 @@ impl PolyfillProtocol {
}
#[cfg(feature = "unstable_protocol_v2")]
Self::V2 => {
let parsed = v2::InitializeResponse::from_value("initialize", response.clone())?;
let parsed = v2::InitializeResponse::from_json("initialize", response.clone())?;
let mcp = parsed
.capabilities
.session
Expand Down
7 changes: 5 additions & 2 deletions src/agent-client-protocol-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ macro_rules! impl_jr_message {
fn to_untyped_message(&self) -> Result<UntypedMessage, crate::Error> {
UntypedMessage::new($method, self)
}
fn parse_message(method: &str, params: &impl Serialize) -> Result<Self, crate::Error> {
fn parse_message(
method: &str,
params: &serde_json::Value,
) -> Result<Self, crate::Error> {
if !Self::matches_method(method) {
return Err(crate::Error::method_not_found());
}
Expand Down Expand Up @@ -183,7 +186,7 @@ macro_rules! impl_jr_response_payload {
fn into_json(self, _method: &str) -> Result<serde_json::Value, crate::Error> {
Ok(serde_json::to_value(self)?)
}
fn from_value(_method: &str, value: serde_json::Value) -> Result<Self, crate::Error> {
fn from_json(_method: &str, value: serde_json::Value) -> Result<Self, crate::Error> {
Ok(serde_json::from_value(value)?)
}
}
Expand Down
Loading
Loading