Skip to content

Commit 1e0a7d6

Browse files
author
Mark Pollack
committed
Cancel requests both ways with $/cancel_request
ACP v1 Cancellation (stable since 2026-06-29), in the client and agent sessions alike. Sending: disposing a request's Mono before its response (the caller, timeout(), or the SDK request timeout) sends $/cancel_request once, after the request itself was written. Never for a request already answered, dismissed by close, or that failed to send; a subscriber that cancels before the request is created sends nothing. The report comes from MonoSink.onCancel, not onDispose, which also runs after an error. The peer's late answer is expected and logged at DEBUG (the last 1,024 cancelled ids are remembered). The send runs on the timeout delivery threads, never on the shared timer. Each subscription to a request now gets its own id: a retry used to reuse it, and the first response completed both. Receiving: InboundRequests wraps a request's complete answer, so a cancel and the handler's answer race inside one Mono (takeUntilOther): exactly one response, the handler's or -32800. The request is cancellable only once its handler is subscribed, and no longer once its value is produced. Once cancelled, a handler error (an interrupted blocking handler throws) counts as cancelled. A cancelled prompt ends its turn before the answer is published; one already cancelled by session/cancel answers stop reason cancelled instead of -32800, as does a prompt handler that fails with a cancellation after session/cancel. $/cancel_request is handled by the session, never offered to notification handlers, and on the client not queued behind the ordered notification drain. Unknown and answered ids are ignored; integer ids match whatever integer type the mapper produced, and "7" is not 7. Graceful cancel: RequestCancellation.cancelWhen(trigger), written into a request's Reactor context, sends $/cancel_request when the trigger fires but keeps waiting, so the request ends with the peer's answer (its result or AcpError -32800). At most one $/cancel_request per request, whichever way it is cancelled. Internal cancellation: CancellationException, an interrupt, or an AcpProtocolException with -32800 from a handler is answered -32800. Closing a session cancels the requests it is still handling. Streamable HTTP: $/cancel_request is connection-scoped both ways, like every $/ method (the routing tables' rule, and the Rust SDK's): the client posts it without Acp-Session-Id, the agent sends it on the connection stream even when its params name a session. A cancel that overtakes its request on another stream finds no request and is ignored, as the spec allows. A received $/cancel_request whose requestId is not a string or integer is ignored: a notification has no response to carry -32602. CancelRequestNotification carries _meta, and its MetaCoverageTest exclusion is gone. Also corrects the G0f note that a blocked sync handler is not interrupted: the sync handler schedulers interrupt on cancel (CHANGELOG, AcpAgent, AcpAgentSupport and @cancel Javadoc). Cross-SDK suite: the Java interop client and agent run the three cancel-request steps (client, agent, unknown id) and their P2 expected failures are deleted; they pass Java<->Java and Java<->TypeScript on stdio, Streamable HTTP and WebSocket, and Java<->Python where Python can. Two Python peer gaps are declared: the Python client routes no $/cancel_request, so an agent's cancelled fs read is never answered -32800 and the client's own step sees the prompt end late. Tests: CancelRequestTest (session level, both sides), an end-to-end test in both directions plus a graceful cancel (red on main), InboundRequestsLincheckTest (exactly one response, no table leak), and PendingResponsesLincheckTest extended (a cancel is sent iff the caller gave up before any signal, at most once). Each model fails on a planted bug.
1 parent fe7567a commit 1e0a7d6

27 files changed

Lines changed: 1892 additions & 144 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
### Added
1717

18+
- **`$/cancel_request` (ACP v1, Cancellation), both directions.** Client and agent sessions alike:
19+
- **Cancelling a request you sent:** dispose (cancel) the subscription to its `Mono` before the
20+
response arrives, directly or through `timeout(...)`, `take...`, or the SDK's own request
21+
timeout, and the session sends `$/cancel_request {"requestId": ...}` to the peer, once, after
22+
the request itself was written (never for a request that failed to send, was already answered,
23+
or was dismissed by closing the session). The caller's `Mono` is cancelled at once (Reactor
24+
semantics); the peer's late answer is expected and discarded at DEBUG. An agent prompt handler
25+
whose subscription is cancelled cancels the `fs/*`, `terminal/*` and
26+
`session/request_permission` requests it is waiting on the same way. Interrupting a thread
27+
blocked in a sync client or agent call cancels its request too.
28+
- **Graceful cancel:** `request.contextWrite(RequestCancellation.cancelWhen(trigger))` sends
29+
`$/cancel_request` when `trigger` emits or completes but keeps waiting, so the request ends with
30+
the peer's answer: its result, or an `AcpError` with code `-32800`. Works for every typed request
31+
method on both sides; at most one `$/cancel_request` per request either way.
32+
- **Answering a cancelled request:** on `$/cancel_request` the session cancels the handler's
33+
subscription and answers the request with error `-32800` (`Request cancelled`), unless the
34+
handler answered first; exactly one response either way (model checked with Lincheck,
35+
`InboundRequestsLincheckTest`). An unknown or already answered id is ignored. A cancelled
36+
`session/prompt` ends its turn before the answer is published; one already cancelled with
37+
`session/cancel` is answered with stop reason `cancelled` instead, as ACP v1 requires of a
38+
cancelled prompt. `$/cancel_request` is handled by the session and never reaches notification
39+
handlers; on the client it is not queued behind slow `session/update` consumers.
40+
- **Internal cancellation:** a handler that fails with `CancellationException`, an interrupt, or
41+
an `AcpProtocolException` with code `-32800` is answered `-32800` (a prompt being cancelled:
42+
stop reason `cancelled`). Closing a session cancels the requests it is still handling and
43+
answers them `-32800` where the transport still delivers.
44+
- Over Streamable HTTP, `$/cancel_request` is connection-scoped both ways, like every `$/`
45+
method and as in the Rust SDK: the client posts it without `Acp-Session-Id`, the agent sends
46+
it on the connection stream. It can therefore overtake a request still on a session stream;
47+
the receiver then ignores it as an unknown id (the spec's MAY) and the request completes.
48+
- Params of a received `$/cancel_request` without a string or integer `requestId` are ignored
49+
(a notification has no response to carry `-32602`).
50+
- `AcpSchema.METHOD_CANCEL_REQUEST`, `AcpSchema.CancelRequestNotification`,
51+
`RequestCancellation`, `AcpSchedulers.timeoutDelivery()`.
52+
- Cross-SDK suite: the Java programs run `cancel-request.client`, `cancel-request.agent` and
53+
`cancel-request.unknown`; their `P2` expected failures are gone.
54+
1855
- **Prompt timeouts on the agent builders** (`AcpAgent.async(...)`, `AcpAgent.sync(...)` and
1956
`AcpAgentSupport.builder()`), also as `PromptTimeouts` on the `AcpAgentSession` constructor:
2057
- `cancelGracePeriod(Duration)`: how long a prompt handler has to answer after `session/cancel`
@@ -30,9 +67,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3067
answer leaves through the normal path: the turn ends just before it is published, it follows
3168
every update the handler had already sent, and exactly one answer is sent when the handler
3269
answers just as a timer fires (model checked with Lincheck, `PromptAnswerLincheckTest`). The
33-
timers run on the SDK's shared timeout timer, with no new threads. A handler that keeps running
34-
after its subscription is cancelled (a blocking sync handler is not interrupted) and sends more
35-
updates sends them after the answer. `AcpErrorCodes.REQUEST_CANCELLED` (`-32800`) is new.
70+
timers run on the SDK's shared timeout timer, with no new threads. Cancelling a sync handler
71+
interrupts its thread (the SDK's sync handler scheduler interrupts on cancel); a handler that
72+
ignores the interrupt, or any handler that keeps running after its subscription is cancelled,
73+
and sends more updates sends them after the answer. `AcpErrorCodes.REQUEST_CANCELLED` (`-32800`) is new.
3674
- **`session_info_update`** (`AcpSchema.SessionInfoUpdate`): the agent tells the client the
3775
session's title and last activity time. Stable in ACP v1. Known limit: the schema lets a peer
3876
send `null` to clear a field; the record reads an explicit `null` like a missing field and never
@@ -71,7 +109,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
71109
common permission-request case (migration: use it, or add `null` for `_meta`). A test walks a copy of the v1.9.1 schema
72110
(`acp-core/src/test/resources/schema/v1/schema.json`) and fails on any object with `_meta`
73111
whose record lacks it. Not covered yet: the elicitation records (with the elicitation work), the
74-
presence markers typed `Object` (they keep `_meta` as a map entry) and `$/cancel_request`.
112+
presence markers typed `Object` (they keep `_meta` as a map entry). `CancelRequestNotification`
113+
carries `_meta` (with `$/cancel_request`, below).
75114

76115
### Changed
77116

@@ -156,6 +195,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
156195
constructor. Migration: `new AgentCapabilities(loadSession, session, mcp, prompt, providers,
157196
meta)` becomes `new AgentCapabilities(loadSession, session, mcp, prompt, auth, providers, meta)`
158197
(`auth` may be `null`); the shorter constructors are unchanged.
198+
- **Behaviour change: a request whose caller gives up is cancelled at the peer.** Disposing a
199+
request `Mono`, or its timeout firing (the client's default request timeout is 30 seconds, the
200+
agent's 60), now sends `$/cancel_request`, so a peer that supports it stops the work and answers
201+
`-32800`. A `session/prompt` that times out on the client is therefore cancelled at an agent that
202+
supports `$/cancel_request` (the Java agent answers it at once and ends the turn), where before
203+
the agent kept running the turn. Migration: raise `requestTimeout` for long prompts, or send
204+
`session/cancel` for the graceful prompt cancel with stop reason `cancelled`.
205+
- **Fix: a resubscribed request is a new request.** Subscribing twice to one `sendRequest` `Mono`
206+
(a `retry()`, say) reused the request id, so the first response completed both. Each
207+
subscription now gets its own id.
159208
- **Behaviour change: `session/cancel` no longer ends the prompt turn; the cancelled prompt's
160209
response does.** The agent session used to free the session for a new prompt as soon as the
161210
cancel notification arrived, so a client could start a second prompt while the cancelled one's

‎acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupport.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -463,8 +463,8 @@ public Builder requestTimeout(Duration timeout) {
463463

464464
/**
465465
* Set how long a {@code @Prompt} method has to return after {@code session/cancel}
466-
* before the agent answers the prompt with stop reason {@code cancelled} itself. The
467-
* method's thread is not interrupted. Default 60 seconds; {@link Duration#ZERO} for
466+
* before the agent answers the prompt with stop reason {@code cancelled} itself and
467+
* interrupts the method's thread. Default 60 seconds; {@link Duration#ZERO} for
468468
* none. See {@code AcpAgent.SyncAgentBuilder#cancelGracePeriod}.
469469
* @param gracePeriod the grace period; zero for none, not negative
470470
* @return this builder

‎acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Cancel.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
* send any last updates, and return stop reason {@code cancelled}. Until it has returned
2424
* (or failed), the session rejects a new prompt (ACP v1, prompt turn, Cancellation). If it
2525
* has not returned within the cancel grace period (60 seconds unless configured on the
26-
* agent builder), the agent answers the prompt {@code cancelled} itself; the method's
27-
* thread is not interrupted.
26+
* agent builder), the agent answers the prompt {@code cancelled} itself and interrupts the
27+
* method's thread.
2828
*
2929
* <p>The method can have the following parameters (all optional):
3030
* <ul>

‎acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgent.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ public SyncAgentBuilder requestTimeout(Duration timeout) {
839839

840840
/**
841841
* Sets how long a prompt handler has to answer after {@code session/cancel}. When it
842-
* passes, the agent cancels the handler (a sync handler blocked on its thread is not interrupted; what it sends afterwards is its own) and answers the prompt itself with stop
842+
* passes, the agent cancels the handler (a sync handler blocked on its thread is interrupted; what it sends if it keeps running is its own) and answers the prompt itself with stop
843843
* reason {@code cancelled}, as ACP requires of a cancelled prompt, which ends the
844844
* turn so the session accepts a new prompt. Updates the handler sent before that
845845
* answer reach the client first. Default: 60 seconds

‎acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpRoutes.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ private enum MethodScope {
7878
Map.entry(AcpSchema.METHOD_PROVIDERS_SET, MethodScope.CONNECTION),
7979
Map.entry(AcpSchema.METHOD_PROVIDERS_DISABLE, MethodScope.CONNECTION),
8080
Map.entry("nes/start", MethodScope.CONNECTION), Map.entry("mcp/message", MethodScope.CONNECTION),
81-
Map.entry("$/cancel_request", MethodScope.CONNECTION),
81+
Map.entry(AcpSchema.METHOD_CANCEL_REQUEST, MethodScope.CONNECTION),
8282
Map.entry(AcpSchema.METHOD_SESSION_LOAD, MethodScope.SESSION_ANSWERED_ON_CONNECTION),
8383
Map.entry(AcpSchema.METHOD_SESSION_RESUME, MethodScope.SESSION_ANSWERED_ON_CONNECTION),
8484
Map.entry(AcpSchema.METHOD_SESSION_PROMPT, MethodScope.SESSION),

‎acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java‎

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.util.Set;
1010
import java.util.function.Function;
1111
import java.util.concurrent.ConcurrentHashMap;
12+
import java.util.concurrent.atomic.AtomicReference;
1213

1314

1415
import com.agentclientprotocol.sdk.error.AcpErrorCodes;
@@ -60,6 +61,9 @@ public class AcpAgentSession implements AcpSession {
6061
/** Map of notification handlers keyed by method name */
6162
private final ConcurrentHashMap<String, NotificationHandler> notificationHandlers = new ConcurrentHashMap<>();
6263

64+
/** Requests from the client still being handled, which a $/cancel_request can cancel */
65+
private final InboundRequests inbound = new InboundRequests();
66+
6367
/** Single-turn enforcement: the active prompt of each logical ACP sessionId. */
6468
private final ActivePrompts activePrompts = new ActivePrompts();
6569

@@ -197,7 +201,11 @@ private Mono<AcpSchema.JSONRPCMessage> handle(AcpSchema.JSONRPCMessage message)
197201
if (message instanceof AcpSchema.JSONRPCRequest request) {
198202
logger.debug("Received request method={} id={}", request.method(), request.id());
199203
// Mono.from widens the response Mono to the message type without an operator.
200-
return Mono.from(handleIncomingRequest(request)
204+
// The whole answer is tracked, so a $/cancel_request races it inside one Mono.
205+
AtomicReference<ActivePrompts.@Nullable Turn> promptTurn = new AtomicReference<>();
206+
return Mono.from(this.inbound
207+
.track(request, handleIncomingRequest(request, promptTurn),
208+
() -> whenCancelled(request, promptTurn.get()))
201209
.onErrorResume(error -> Mono.just(InboundMessages.error(request, error))));
202210
}
203211
if (message instanceof AcpSchema.JSONRPCNotification notification) {
@@ -214,7 +222,8 @@ private Mono<AcpSchema.JSONRPCMessage> handle(AcpSchema.JSONRPCMessage message)
214222
* @param request The incoming JSON-RPC request
215223
* @return A Mono containing the JSON-RPC response
216224
*/
217-
private Mono<AcpSchema.JSONRPCResponse> handleIncomingRequest(AcpSchema.JSONRPCRequest request) {
225+
private Mono<AcpSchema.JSONRPCResponse> handleIncomingRequest(AcpSchema.JSONRPCRequest request,
226+
AtomicReference<ActivePrompts.@Nullable Turn> promptTurn) {
218227
return Mono.defer(() -> {
219228
var handler = this.requestHandlers.get(request.method());
220229
if (handler == null) {
@@ -233,17 +242,22 @@ private Mono<AcpSchema.JSONRPCResponse> handleIncomingRequest(AcpSchema.JSONRPCR
233242
"There is already an active prompt on session " + sessionId,
234243
Map.of("sessionId", sessionId)));
235244
}
245+
promptTurn.set(turn);
236246

237247
// The turn ends before the response is published (#14): see
238248
// ActivePrompts.endBeforePublishing. Mono.defer keeps a handler that throws
239249
// synchronously from holding the turn forever: the throw becomes an error
240250
// signal that passes through the release.
241251
// The deadlines may answer instead of the handler; either answer passes
242252
// through the release.
253+
// A handler whose work was aborted after session/cancel answers cancelled, not
254+
// an error (ACP v1, prompt turn, Cancellation).
243255
return activePrompts.endBeforePublishing(turn, promptDeadlines.answer(turn, request,
244256
InboundMessages.requireResult(Mono.defer(() -> handler.handle(InboundMessages.paramsOrEmpty(request.params()))),
245257
request.method())
246-
.map(result -> InboundMessages.result(request, result))));
258+
.map(result -> InboundMessages.result(request, result))
259+
.onErrorResume(error -> InboundMessages.isCancellation(error) && turn.answer().isCancelling(),
260+
error -> Mono.just(cancelledPrompt(request)))));
247261
}
248262

249263
return InboundMessages.requireResult(Mono.defer(() -> handler.handle(InboundMessages.paramsOrEmpty(request.params()))),
@@ -252,6 +266,23 @@ private Mono<AcpSchema.JSONRPCResponse> handleIncomingRequest(AcpSchema.JSONRPCR
252266
});
253267
}
254268

269+
/**
270+
* The answer to a request a $/cancel_request cancelled: -32800, except for a prompt
271+
* already cancelled by session/cancel, which must answer stop reason cancelled (ACP v1,
272+
* prompt turn, Cancellation).
273+
*/
274+
private static AcpSchema.JSONRPCResponse whenCancelled(AcpSchema.JSONRPCRequest request,
275+
ActivePrompts.@Nullable Turn promptTurn) {
276+
if (promptTurn != null && promptTurn.answer().isCancelling()) {
277+
return cancelledPrompt(request);
278+
}
279+
return InboundRequests.cancelled(request);
280+
}
281+
282+
private static AcpSchema.JSONRPCResponse cancelledPrompt(AcpSchema.JSONRPCRequest request) {
283+
return InboundMessages.result(request, new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED));
284+
}
285+
255286
/**
256287
* Extracts the sessionId from request parameters, which arrive as a {@code Map} from
257288
* the JSON transports and as the typed record from in-process transports.
@@ -282,12 +313,20 @@ private MethodNotFoundError getMethodNotFoundError(String method) {
282313
/**
283314
* Handles an incoming JSON-RPC notification by routing it to the appropriate handler.
284315
* A session/cancel notification does not end the active prompt's turn: the cancelled
285-
* prompt's response does (see ActivePrompts#cancel).
316+
* prompt's response does (see ActivePrompts#cancel). A $/cancel_request cancels the
317+
* request it names, which is then answered with -32800 unless it already answered; a
318+
* cancelled prompt's turn ends before that answer is published.
286319
* @param notification The incoming JSON-RPC notification
287320
* @return A Mono that completes when the notification is processed
288321
*/
289322
private Mono<Void> handleIncomingNotification(AcpSchema.JSONRPCNotification notification) {
290323
return Mono.defer(() -> {
324+
// $/cancel_request: answered by the session itself, never by a handler
325+
if (AcpSchema.METHOD_CANCEL_REQUEST.equals(notification.method())) {
326+
this.inbound.cancel(notification);
327+
return Mono.empty();
328+
}
329+
291330
// session/cancel: the turn stays until the cancelled prompt answers
292331
if (AcpSchema.METHOD_SESSION_CANCEL.equals(notification.method())) {
293332
activePrompts.cancel(extractSessionId(notification.params()));
@@ -371,6 +410,7 @@ public Set<String> getActivePromptSessionIds() {
371410
@Override
372411
public Mono<Void> closeGracefully() {
373412
return Mono.fromRunnable(() -> {
413+
this.inbound.cancelAll();
374414
activePrompts.clear();
375415
dismissPendingResponses();
376416
}).then(this.transport.closeGracefully());
@@ -381,6 +421,7 @@ public Mono<Void> closeGracefully() {
381421
*/
382422
@Override
383423
public void close() {
424+
this.inbound.cancelAll();
384425
activePrompts.clear();
385426
dismissPendingResponses();
386427
transport.close();

‎acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ public class AcpClientSession implements AcpSession {
6464
/** Requests and notifications sent to the agent, and the requests waiting for a response */
6565
private final OutboundMessages outbound;
6666

67+
/** Requests from the agent still being handled, which a $/cancel_request can cancel */
68+
private final InboundRequests inbound = new InboundRequests();
69+
6770
/** Map of request handlers keyed by method name */
6871
private final ConcurrentHashMap<String, RequestHandler<?>> requestHandlers = new ConcurrentHashMap<>();
6972

@@ -233,18 +236,29 @@ else if (message instanceof AcpSchema.JSONRPCRequest request) {
233236
respondTo(request);
234237
}
235238
else if (message instanceof AcpSchema.JSONRPCNotification notification) {
236-
enqueue(notification);
239+
if (AcpSchema.METHOD_CANCEL_REQUEST.equals(notification.method())) {
240+
// Not queued behind the ordered notification drain: a slow session/update
241+
// consumer must not delay cancelling a pending request.
242+
this.inbound.cancel(notification);
243+
}
244+
else {
245+
enqueue(notification);
246+
}
237247
}
238248
else {
239249
logger.warn("Received unknown message type: {}", message.getClass().getName());
240250
}
241251
}
242252

243-
/** Answers a request from the agent; a failed handler is answered with an error response. */
253+
/**
254+
* Answers a request from the agent; a failed handler is answered with an error response,
255+
* a request cancelled by $/cancel_request with -32800 unless it already answered.
256+
*/
244257
private void respondTo(AcpSchema.JSONRPCRequest request) {
245258
logger.debug("Received request method={} id={}", request.method(), request.id());
246259
logger.trace("Incoming request method='{}' id={}", request.method(), request.id());
247-
handleIncomingRequest(request).onErrorResume(error -> Mono.just(InboundMessages.error(request, error)))
260+
this.inbound.track(request, handleIncomingRequest(request))
261+
.onErrorResume(error -> Mono.just(InboundMessages.error(request, error)))
248262
.flatMap(this.transport::sendMessage)
249263
.onErrorComplete(t -> {
250264
logger.warn("Issue sending response to the agent, ", t);
@@ -376,6 +390,7 @@ public Mono<Void> sendNotification(String method, @Nullable Object params) {
376390
public Mono<Void> closeGracefully() {
377391
return Mono.<Void>fromRunnable(() -> {
378392
this.closing = true;
393+
this.inbound.cancelAll();
379394
dismissPendingResponses();
380395
// Never lost to a notification being delivered concurrently (NotificationQueue).
381396
this.notifications.complete();
@@ -396,6 +411,7 @@ public Mono<Void> closeGracefully() {
396411
@Override
397412
public void close() {
398413
this.closing = true;
414+
this.inbound.cancelAll();
399415
dismissPendingResponses();
400416
this.notifications.complete();
401417
notificationSubscription.dispose();

0 commit comments

Comments
 (0)