Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c0c1a2f
feat(acp): add request cancellation support
benbrandt May 20, 2026
7bfa50a
refactor(acp): remove cancel request wrapper
benbrandt May 20, 2026
6461dad
Clippy
benbrandt May 21, 2026
ddb19ba
feat(acp): Auto-cancel dropped sent requests
benbrandt May 21, 2026
25bea00
fix(acp): disarm cancellation for buffered responses
benbrandt May 21, 2026
506ec3f
docs: clarify automatic request cancellation behavior
benbrandt May 21, 2026
6384007
Merge branch 'main' into request-cancellation
benbrandt Jun 10, 2026
0065bd3
chore(acp): acknowledge large session enum variant
benbrandt Jun 10, 2026
ca4d3dd
revert changelog changes
benbrandt Jun 10, 2026
5df098d
docs(acp): expand request cancellation guidance and various fixes
benbrandt Jun 10, 2026
f02ea56
fix(acp): Defer cancellation marker allocation
benbrandt Jun 10, 2026
6b79fff
fix(acp): handle wrapped request cancellation correctly
benbrandt Jun 10, 2026
9f9c616
fix(acp): Serialize all cancel notifications correctly
benbrandt Jun 10, 2026
6d08e6c
Merge branch 'main' into request-cancellation
benbrandt Jun 10, 2026
1965e6c
fix(acp): Drop proxied cancel notifications
benbrandt Jun 11, 2026
ce4f6be
feat(acp): Allow custom cancellation forwarding
benbrandt Jun 11, 2026
cee9686
fix(acp): Drop wrapped cancel notifications
benbrandt Jun 11, 2026
637da96
fix(acp): Disarm cancellation for claimed responses
benbrandt Jun 11, 2026
d96af2b
fix(acp): disarm cancellation when routing responses
benbrandt Jun 13, 2026
74c89f0
fix(acp): Propagate proxy session cancellation
benbrandt Jun 13, 2026
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
1 change: 1 addition & 0 deletions md/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- [Design Overview](./design.md)
- [Protocol Reference](./protocol.md)
- [Request Cancellation](./request-cancellation.md)
- [Protocol V2](./protocol-v2.md)

# Conductor (agent-client-protocol-conductor)
Expand Down
109 changes: 109 additions & 0 deletions md/request-cancellation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Request Cancellation

This chapter documents the `$/cancel_request` protocol-level notification and
how the SDK implements it.

For API usage (cancelling a `SentRequest`, observing cancellation from a
`Responder`), see the `concepts::cancellation` chapter in the
[agent-client-protocol rustdoc](https://docs.rs/agent-client-protocol). The
SDK support is gated behind the `unstable_cancel_request` feature:

```toml
agent-client-protocol = { version = "...", features = ["unstable_cancel_request"] }
```

## The `$/cancel_request` Notification

Either side of a connection may send `$/cancel_request` to ask the peer to
cancel one outstanding JSON-RPC request, identified by its ID:

```json
{
"jsonrpc": "2.0",
"method": "$/cancel_request",
"params": {
"requestId": "70b9f1c9-c2a3-4bd2-b6b9-65a06d96b675"
}
}
```

`requestId` is the JSON-RPC `id` of the request to cancel, as allocated by the
sender of that request (a string, number, or null).

## Semantics

Cancellation is **cooperative**. After receiving `$/cancel_request`, the peer
may:

- ignore it and respond to the request normally,
- finish early with whatever data it has, or
- respond to the original request with the standard cancellation error,
code `-32800` ("Request cancelled").

The requesting side always receives a response to the original request;
cancellation only changes _which_ response that is. A `$/cancel_request` for
an unknown or already-completed request ID is silently ignored. A
`$/cancel_request` with malformed params (for example, a `requestId` that is
not a string, number, or null) is different: when the receiver is built with
the `unstable_cancel_request` feature, it is reported back with an
out-of-band error notification, like any other malformed notification. A
receiver built without the feature never parses the params and ignores the
notification like any other unhandled `$/` notification (see
[Interoperability](#interoperability)).

## Interoperability

Protocol-level (`$/`-prefixed) notifications are optional by design. The SDK
ignores unhandled `$/` notifications instead of rejecting them with a
method-not-found error, and does so even when the `unstable_cancel_request`
feature is disabled. A peer that sends `$/cancel_request` to a component built
without cancellation support therefore loses nothing: the request simply runs
to completion.

## Proxy Chains

Cancellation propagates **hop by hop** rather than end to end. Request IDs are
allocated per connection, so a `$/cancel_request` only ever refers to a
request on the connection it is sent over:

1. The client sends `$/cancel_request` for a request it made to its direct
peer (for example, a proxy).
2. A proxy that forwarded the request downstream (the SDK does this with
`forward_response_to`) reacts by sending its own `$/cancel_request` for the
downstream request, using the downstream connection's request ID.
3. The downstream response — normal data or the cancellation error — flows
back up the chain as the response to each hop's request.

Because the notification is hop-scoped, it is never tunneled across hops:
when the feature is enabled, generic forwarding helpers
(`send_proxied_message_to` in the SDK, and the conductor's internal routing)
drop a raw `$/cancel_request` instead of forwarding a request ID that means
nothing on the next connection. The cancellation still reaches the next hop,
re-issued by `forward_response_to` with that hop's own request ID.

Proxies that intercept methods with custom handlers stay in control: the
request's cancellation marker is their decision point, and handlers see the
raw notification before any generic forwarding fallback. A custom handler can
handle the cancellation locally, propagate it to a forwarded request
(`forward_response_to`, or `forward_cancellation_from` when the forwarding
needs custom logic), absorb it, or claim the notification and route it itself.
See the `concepts::cancellation` chapter in the
[agent-client-protocol rustdoc](https://docs.rs/agent-client-protocol) for
the full decision matrix.

When the notification targets a request that was wrapped in a
`_proxy/successor` envelope (see the [Protocol Reference](./protocol.md)), the
`$/cancel_request` is wrapped in the same envelope, and `requestId` refers to
the JSON-RPC `id` of the wrapped request on that connection.

The conductor translates cancellations between hops when it is built with its
`unstable_cancel_request` feature, which forwards the feature of the same name
to the SDK. Without it, no per-hop cancellation is issued; since request IDs
are reallocated at every hop, a `$/cancel_request` cannot match anything
beyond the hop it was sent over, and the affected request simply runs to
completion as described in [Interoperability](#interoperability).

## Related Documentation

- [Protocol Reference](./protocol.md) - The `_proxy/successor/*` envelope protocol
- [agent-client-protocol rustdoc](https://docs.rs/agent-client-protocol) - SDK API for sending, observing, and forwarding cancellations (see `concepts::cancellation`)
7 changes: 7 additions & 0 deletions src/agent-client-protocol-conductor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ categories = ["development-tools"]
name = "agent-client-protocol-conductor"
path = "src/main.rs"

[features]
default = []

# Forwarded from agent-client-protocol. Enable to let the conductor forward
# `$/cancel_request` hop by hop through the proxy chain.
unstable_cancel_request = ["agent-client-protocol/unstable_cancel_request"]

[dependencies]
agent-client-protocol = { workspace = true }
agent-client-protocol-trace-viewer.workspace = true
Expand Down
43 changes: 32 additions & 11 deletions src/agent-client-protocol-conductor/src/conductor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,26 @@ where
Dispatch::Request(request, responder) => self
.send_request_to_predecessor_of(client, source_component_index, request)
.forward_response_to(responder),
Dispatch::Notification(notification) => self.send_notification_to_predecessor_of(
client,
source_component_index,
notification,
),
Dispatch::Notification(notification) => {
// `$/cancel_request` is connection-scoped: its `requestId` was
// allocated on the connection the notification arrived over
// and means nothing on the predecessor's connection. The SDK
// already propagates the cancellation hop by hop through the
// `forward_response_to` calls above, so drop the raw
// notification instead of tunneling a meaningless ID.
#[cfg(feature = "unstable_cancel_request")]
if agent_client_protocol::is_cancel_request_notification(&notification) {
tracing::debug!(
"not forwarding hop-scoped `$/cancel_request` notification to predecessor"
);
return Ok(());
}
self.send_notification_to_predecessor_of(
client,
source_component_index,
notification,
)
}
Dispatch::Response(result, router) => router.respond_with_result(result),
}
}
Expand Down Expand Up @@ -763,12 +778,18 @@ where
//
// The proxy will then initialize itself and forward an `Initialize`
// request to its successor.
self.proxies[target_component_index]
.send_request(InitializeProxyRequest::from(request))
.on_receiving_result(async move |result| {
tracing::debug!(?result, "got initialize_proxy response from proxy");
responder.respond_with_result(result)
})
let sent = self.proxies[target_component_index]
.send_request(InitializeProxyRequest::from(request));
// The request is rewritten, so `forward_response_to` cannot be
// used here; wire up cancellation forwarding explicitly to
// keep `initialize` cancellable like every other forwarded
// request.
#[cfg(feature = "unstable_cancel_request")]
let sent = sent.forward_cancellation_from(responder.cancellation());
sent.on_receiving_result(async move |result| {
tracing::debug!(?result, "got initialize_proxy response from proxy");
responder.respond_with_result(result)
})
})
.await
.otherwise(async |message| {
Expand Down
Loading