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
43 changes: 42 additions & 1 deletion md/request-cancellation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ 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
`Responder`, or retaining a `RequestCancellationHandle` alongside a callback),
see the `concepts::cancellation` chapter in the
[agent-client-protocol rustdoc](https://docs.rs/agent-client-protocol).

## The `$/cancel_request` Notification
Expand Down Expand Up @@ -56,6 +57,46 @@ but which should continue running on the peer. The peer is still expected to
answer the JSON-RPC request eventually; use a notification instead when no
response is expected at all.

## Retaining Cancellation Control

Before consuming a `PreparedRequest` or `SentRequest`, call
`cancellation_handle()` to retain a cloneable `RequestCancellationHandle`.
Its `cancel()` method requests cancellation independently of response consumption:
an ordered callback or response future still receives the eventual result.

The handle shares once-only cancellation and settlement state with
`SentRequest::cancel`, forwarded cancellation, and automatic request-drop
cancellation. It uses the same peer and proxy wrapping. Dropping the handle
neither sends cancellation nor disables automatic request-drop cancellation.
To cancel when a caller is abandoned, its application-owned guard or abandonment
handler must explicitly call `cancel()` and handle any immediate error.

Calling it before a prepared request is published does nothing and is not
remembered for later publication. A call racing publication may also do nothing;
hand control to independent cancellers after the publishing method returns.
Once the SDK routes a response or fails the request, new cancellation calls do
nothing—even if the application has not consumed the result yet. An attempt begun
before settlement may still enqueue afterward.

Detachment is separate from settlement: `detach()` discards the response and
suppresses automatic cancellation on drop, but does not revoke retained explicit
handles. They can still cancel the detached request while it remains pending.

`Ok(())` means the call encountered no immediate error, not that a notification
was sent or the peer stopped work. Cancellation is cooperative; it does not
guarantee transmission, peer cooperation, or a successful response. A handle does
not abort local callback work or keep the connection driver/response consumer alive.

## Request Cancellation Is Not Session Cancellation

`$/cancel_request` targets one pending JSON-RPC request, not the lifetime of the
session work it may start. In ACP v2, `session/prompt` succeeds once the user
message is inserted and returns its `messageId`. After insertion the Agent must
return success, not `-32800`, even if cancellation races the response. Stopping
active session work uses `session/cancel`. See the
[v2 cancellation specification](https://agentclientprotocol.com/protocol/v2/cancellation)
and [prompt lifecycle](https://agentclientprotocol.com/protocol/v2/prompt-lifecycle#2-prompt-accepted).

## Proxy Chains

Cancellation propagates **hop by hop** rather than end to end. Request IDs are
Expand Down
29 changes: 29 additions & 0 deletions md/sending-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,35 @@ without cancelling. It does not select ordered consumption.
Outgoing order follows publication, not preparation. A notification sent
between preparation and consumption enters the queue before the request.

## Cancellation after selecting a consumer

Obtain `cancellation_handle()` before consuming a prepared or sent request to
retain explicit cancellation control alongside its callback or response future:

```rust
let prepared = connection.prepare_request(request);
let cancellation = prepared.cancellation_handle();
prepared.on_receiving_result(async move |result| {
application_queue.enqueue(result)?;
Ok(())
})?;
caller.on_abandoned(move || cancellation.cancel());
```

Hand cancellation control to the caller's abandonment handler only after
publication returns, and have that handler deal with any immediate error.
Merely dropping the public handle does not cancel. The callback owns eventual
completion and any late-resource cleanup independently of the caller.

Cancelling does not discard the eventual result. The cloneable handle shares
SDK settlement and once-only cancellation state, rather than sending an
unconditional notification for a saved request ID. Dropping the handle does
nothing. Calls before publication or after a response/local failure do nothing.
Detachment suppresses automatic cancellation but preserves retained explicit
control until settlement. `Ok(())` is not acknowledgment that cancellation was
sent or took effect. See [Request Cancellation](./request-cancellation.md) for
the full contract, including publication races and peer wrapping.

## Drop and errors

Dropping an unconsumed `PreparedRequest` sends nothing. Dropping the response
Expand Down
8 changes: 8 additions & 0 deletions src/agent-client-protocol/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

### Added

- Add `RequestCancellationHandle` and `cancellation_handle()` on prepared and
sent requests. Retain explicit cancellation control while an ordered callback
or future consumes the response, sharing SDK publication/settlement state and
once-only cancellation. Handle drop is inert; detaching a request suppresses
automatic cancellation without revoking retained explicit control.

## [3.1.0](https://github.com/agentclientprotocol/rust-sdk/compare/v3.0.0...v3.1.0) - 2026-10-07

### Added
Expand Down
69 changes: 67 additions & 2 deletions src/agent-client-protocol/src/concepts/cancellation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,68 @@
//! original request, so this also works for requests sent through
//! [`ConnectionTo::send_request_to`].
//!
//! When another task needs to request cancellation after the request is consumed,
//! retain a [`RequestCancellationHandle`] first. Its drop is inert; an
//! application-owned guard can explicitly cancel when the caller is abandoned:
//!
//! ```
//! # use agent_client_protocol::{ConnectionTo, Error, RequestCancellationHandle, UntypedRole};
//! # use agent_client_protocol_test::{MyRequest, MyResponse};
//! # use futures::channel::oneshot;
//! # fn apply_result(_result: Result<MyResponse, Error>) {}
//! # fn clean_up_abandoned_result(_result: Result<MyResponse, Error>) {}
//! struct CancelOnAbandonment(RequestCancellationHandle);
//!
//! impl Drop for CancelOnAbandonment {
//! fn drop(&mut self) {
//! if let Err(error) = self.0.cancel() {
//! eprintln!("Failed to request cancellation: {}", error.code);
//! }
//! }
//! }
//!
//! # async fn example(cx: ConnectionTo<UntypedRole>) -> Result<(), Error> {
//! let request = cx.prepare_request(MyRequest {});
//! let cancellation = request.cancellation_handle();
//! let (result_sender, result_received) = oneshot::channel();
//! request.on_receiving_result(async move |result| {
//! if let Err(result) = result_sender.send(result) {
//! clean_up_abandoned_result(result);
//! }
//! Ok(())
//! })?;
//! let _caller_guard = CancelOnAbandonment(cancellation);
//! let result = result_received.await.map_err(Error::into_internal_error)?;
//! apply_result(result);
//! # Ok(())
//! # }
//! ```
//!
//! Install the guard in the caller's scope, not the response callback, and only
//! after the publishing method returns: cancellation before publication is not
//! remembered. If the caller is abandoned, the guard requests cancellation; the
//! selected callback remains responsible for the eventual result or cleanup.
//!
//! Explicit cancellation does not discard the selected callback's or future's
//! response. Cloned handles share the same once-only cancellation state as
//! [`SentRequest::cancel`] and automatic request-drop cancellation, and remember
//! the original peer and proxy wrapping. Dropping a cancellation handle does
//! nothing. Cancelling a prepared request before publication is also a no-op; it is not remembered for
//! later publication. Responses and local failures disarm cancellation before
//! application callbacks run. Detaching suppresses only automatic cancellation:
//! retained handles can explicitly cancel a detached request while it is pending.
//!
//! `cancel()` returning `Ok(())` is not acknowledgment of transmission or peer
//! cooperation. It may have been a no-op, and an attempt begun before settlement
//! can still enqueue afterward. Cancellation controls peer request work, not
//! local callback work, and retaining a handle does not keep the connection
//! driver or response consumer alive.
//!
//! In ACP v2, a successful `session/prompt` response means the user message was
//! inserted. The request is then complete, even if session work continues:
//! stopping that work requires `session/cancel`, not request cancellation.
//! See the [v2 prompt lifecycle](https://agentclientprotocol.com/protocol/v2/prompt-lifecycle#2-prompt-accepted).
//!
//! Dropping a [`SentRequest`] before the SDK receives a response also sends
//! `$/cancel_request`. This covers abandoned request handles and futures. For a
//! request whose eventual response should be ignored, but which should continue
Expand Down Expand Up @@ -164,8 +226,9 @@
//! If you are implementing custom routing and already know the JSON-RPC request
//! ID on the peer connection you are targeting, use
//! [`ConnectionTo::send_cancel_request_to`]. Most code should use
//! [`SentRequest::cancel`] instead, because the request handle already knows the
//! correct peer, request ID, and proxy wrapping.
//! [`SentRequest::cancel`] or [`RequestCancellationHandle::cancel`] instead,
//! because they share SDK settlement state and already know the correct peer,
//! request ID, and proxy wrapping.
//!
//! [`block_task`]: crate::SentRequest::block_task
//! [`on_receiving_result`]: crate::SentRequest::on_receiving_result
Expand All @@ -180,6 +243,8 @@
//! [`ConnectionTo::spawn`]: crate::ConnectionTo::spawn
//! [`SentRequest`]: crate::SentRequest
//! [`SentRequest::cancel`]: crate::SentRequest::cancel
//! [`RequestCancellationHandle`]: crate::RequestCancellationHandle
//! [`RequestCancellationHandle::cancel`]: crate::RequestCancellationHandle::cancel
//! [`SentRequest::detach`]: crate::SentRequest::detach
//! [`forward_cancellation_from`]: crate::SentRequest::forward_cancellation_from
//! [`ConnectionTo::send_cancel_request_to`]: crate::ConnectionTo::send_cancel_request_to
Expand Down
Loading
Loading