Skip to content

Commit c677c9e

Browse files
author
Mark Pollack
committed
Let a prompt handler see its prompt's cancellation from its context
SyncPromptContext.isCancelled()/onCancel(Runnable) and PromptContext.isCancelled()/ whenCancelled() signal session/cancel for the prompt's session, $/cancel_request for its request, and the agent's own cancellation of the handler. An annotated @prompt method no longer needs a @cancel handler and shared state to stop. Adds PromptResponse.cancelled().
1 parent 3d46165 commit c677c9e

13 files changed

Lines changed: 740 additions & 34 deletions

File tree

‎CHANGELOG.md‎

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

1616
### Added
1717

18+
- **Prompt handlers see their prompt's cancellation.** `SyncPromptContext.isCancelled()` and
19+
`onCancel(Runnable)`, and `PromptContext.isCancelled()` and `whenCancelled()` (a `Mono<Void>`
20+
that completes on cancel), signal a cancel by `session/cancel` for the prompt's session or by
21+
`$/cancel_request` for its request, and also when the agent cancels the handler itself (the
22+
cancel grace period or `maxPromptDuration` passed, or the connection closed). An annotated
23+
`@Prompt` method reads it from the context it takes, with no `@Cancel` handler or shared state;
24+
a builder prompt handler likewise. After `session/cancel` the handler still answers `cancelled`
25+
within the cancel grace period; after `$/cancel_request` the agent has already answered, so the
26+
handler just stops. New `PromptResponse.cancelled()`.
27+
**Breaking** for code implementing `PromptContext` or `SyncPromptContext` itself (test doubles):
28+
implement the new methods.
29+
1830
- **`$/cancel_request` (ACP v1, Cancellation), both directions.** Client and agent sessions alike:
1931
- **Cancelling a request you sent:** dispose (cancel) the subscription to its `Mono` before the
2032
response arrives, directly or through `timeout(...)`, `take...`, or the SDK's own request

‎acp-agent-support/README.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,39 @@ PromptResponse handle(PromptRequest req, SyncPromptContext ctx) {
261261
}
262262
```
263263

264+
### Cancellation
265+
266+
A prompt method learns that its prompt was cancelled from its context, whether by `session/cancel`
267+
for its session or by `$/cancel_request` for its request; no `@Cancel` handler or shared state is
268+
needed:
269+
270+
```java
271+
@Prompt
272+
PromptResponse handle(PromptRequest req, SyncPromptContext ctx) {
273+
for (Step step : plan(req)) {
274+
if (ctx.isCancelled()) {
275+
ctx.sendMessage("Stopped.");
276+
return PromptResponse.cancelled();
277+
}
278+
step.run(ctx);
279+
}
280+
return PromptResponse.endTurn();
281+
}
282+
283+
@Prompt
284+
Mono<PromptResponse> handle(PromptRequest req, PromptContext ctx) {
285+
return work(req, ctx)
286+
.takeUntilOther(ctx.whenCancelled())
287+
.defaultIfEmpty(PromptResponse.cancelled());
288+
}
289+
```
290+
291+
`SyncPromptContext.onCancel(Runnable)` runs an action once on cancel, for work that cannot poll
292+
(a subprocess, an HTTP call). After `session/cancel` the method must answer `cancelled` within the
293+
cancel grace period (`cancelGracePeriod`, default 60 s); once it passes the agent answers
294+
`cancelled` itself and interrupts the method's thread. After `$/cancel_request` the agent has already
295+
answered (`-32800`, or `cancelled` if the session was cancelled too), so the method just stops.
296+
264297
## Interceptors
265298

266299
Interceptors allow cross-cutting concerns like logging, metrics, or error handling:
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
*/
4+
5+
package com.agentclientprotocol.sdk.agent.support;
6+
7+
import java.time.Duration;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.concurrent.CountDownLatch;
11+
import java.util.concurrent.TimeUnit;
12+
13+
import com.agentclientprotocol.sdk.agent.PromptContext;
14+
import com.agentclientprotocol.sdk.agent.SyncPromptContext;
15+
import com.agentclientprotocol.sdk.annotation.AcpAgent;
16+
import com.agentclientprotocol.sdk.annotation.Cancel;
17+
import com.agentclientprotocol.sdk.annotation.NewSession;
18+
import com.agentclientprotocol.sdk.annotation.Prompt;
19+
import com.agentclientprotocol.sdk.client.AcpAsyncClient;
20+
import com.agentclientprotocol.sdk.client.AcpClient;
21+
import com.agentclientprotocol.sdk.spec.AcpSchema;
22+
import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest;
23+
import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse;
24+
import com.agentclientprotocol.sdk.test.InMemoryTransportPair;
25+
import org.junit.jupiter.api.Test;
26+
import reactor.core.Disposable;
27+
import reactor.core.publisher.Mono;
28+
29+
import static org.assertj.core.api.Assertions.assertThat;
30+
31+
/**
32+
* An annotated {@code @Prompt} method learns of its prompt's cancellation from its context,
33+
* with no {@code @Cancel} handler or shared state: {@code SyncPromptContext.isCancelled()} or
34+
* {@code PromptContext.whenCancelled()}. The grace period is long, so a prompt answered
35+
* promptly with the handler's {@code _meta} was answered by the handler.
36+
*/
37+
class AnnotatedPromptCancellationTest {
38+
39+
private static final Duration TIMEOUT = Duration.ofSeconds(5);
40+
41+
private static final String SESSION = "annotated-cancel";
42+
43+
private static final Map<String, Object> HANDLERS_OWN = Map.of("answeredBy", "handler");
44+
45+
@AcpAgent
46+
static class PollingAgent {
47+
48+
final CountDownLatch running = new CountDownLatch(1);
49+
50+
final CountDownLatch sawCancel = new CountDownLatch(1);
51+
52+
final CountDownLatch cancelHandlerRan = new CountDownLatch(1);
53+
54+
@NewSession
55+
AcpSchema.NewSessionResponse newSession() {
56+
return new AcpSchema.NewSessionResponse(SESSION, null, null);
57+
}
58+
59+
@Prompt
60+
PromptResponse prompt(PromptRequest request, SyncPromptContext context) {
61+
running.countDown();
62+
while (!context.isCancelled()) {
63+
Thread.interrupted(); // a cancelled request interrupts the thread: keep polling
64+
pause();
65+
}
66+
sawCancel.countDown();
67+
return new PromptResponse(AcpSchema.StopReason.CANCELLED, HANDLERS_OWN);
68+
}
69+
70+
@Cancel
71+
void cancel() {
72+
cancelHandlerRan.countDown();
73+
}
74+
75+
}
76+
77+
@AcpAgent
78+
static class ReactiveAgent {
79+
80+
@NewSession
81+
AcpSchema.NewSessionResponse newSession() {
82+
return new AcpSchema.NewSessionResponse(SESSION, null, null);
83+
}
84+
85+
@Prompt
86+
Mono<PromptResponse> prompt(PromptContext context) {
87+
return Mono.<PromptResponse>never()
88+
.takeUntilOther(context.whenCancelled())
89+
.switchIfEmpty(Mono.fromSupplier(
90+
() -> new PromptResponse(AcpSchema.StopReason.CANCELLED, HANDLERS_OWN)));
91+
}
92+
93+
}
94+
95+
@Test
96+
void aPollingPromptAnswersCancelledPromptlyOnSessionCancel() throws Exception {
97+
PollingAgent bean = new PollingAgent();
98+
run(bean, client -> {
99+
Mono<PromptResponse> response = client.prompt(prompt()).cache();
100+
response.subscribe(r -> {
101+
}, e -> {
102+
});
103+
await(bean.running);
104+
long start = System.nanoTime();
105+
client.cancel(new AcpSchema.CancelNotification(SESSION)).block(TIMEOUT);
106+
107+
PromptResponse answer = response.block(TIMEOUT);
108+
assertThat(answer.stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED);
109+
assertThat(answer.meta()).as("the handler's own answer").isEqualTo(HANDLERS_OWN);
110+
assertThat(Duration.ofNanos(System.nanoTime() - start)).isLessThan(Duration.ofSeconds(3));
111+
await(bean.cancelHandlerRan);
112+
});
113+
}
114+
115+
@Test
116+
void aPollingPromptSeesACancelRequestForItsRequest() throws Exception {
117+
PollingAgent bean = new PollingAgent();
118+
run(bean, client -> {
119+
Disposable prompt = client.prompt(prompt()).subscribe(r -> {
120+
}, e -> {
121+
});
122+
await(bean.running);
123+
124+
prompt.dispose(); // $/cancel_request
125+
126+
await(bean.sawCancel);
127+
});
128+
}
129+
130+
@Test
131+
void aReactivePromptCompletesOnWhenCancelled() throws Exception {
132+
run(new ReactiveAgent(), client -> {
133+
Mono<PromptResponse> response = client.prompt(prompt()).cache();
134+
response.subscribe(r -> {
135+
}, e -> {
136+
});
137+
pause();
138+
client.cancel(new AcpSchema.CancelNotification(SESSION)).block(TIMEOUT);
139+
140+
assertThat(response.block(TIMEOUT).meta()).isEqualTo(HANDLERS_OWN);
141+
});
142+
}
143+
144+
interface ClientScenario {
145+
146+
void run(AcpAsyncClient client) throws Exception;
147+
148+
}
149+
150+
private static void run(Object bean, ClientScenario scenario) throws Exception {
151+
InMemoryTransportPair pair = InMemoryTransportPair.create();
152+
AcpAgentSupport agent = AcpAgentSupport.create(bean)
153+
.transport(pair.agentTransport())
154+
.requestTimeout(TIMEOUT)
155+
.cancelGracePeriod(Duration.ofSeconds(30))
156+
.build();
157+
agent.start();
158+
AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build();
159+
try {
160+
client.initialize().block(TIMEOUT);
161+
client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())).block(TIMEOUT);
162+
scenario.run(client);
163+
}
164+
finally {
165+
client.closeGracefully().block(TIMEOUT);
166+
agent.close();
167+
}
168+
}
169+
170+
private static PromptRequest prompt() {
171+
return new PromptRequest(SESSION, List.of(new AcpSchema.TextContent("work")));
172+
}
173+
174+
private static void await(CountDownLatch latch) throws InterruptedException {
175+
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
176+
}
177+
178+
private static void pause() {
179+
try {
180+
Thread.sleep(20);
181+
}
182+
catch (InterruptedException ex) {
183+
Thread.currentThread().interrupt();
184+
}
185+
}
186+
187+
}

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

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
* Marks the {@link AcpAgent} method that receives {@code session/cancel}: the client asks the
1515
* agent to stop the session's running prompt turn, and the method tells the {@link Prompt} method
1616
* to stop. {@code session/cancel} is a notification, so the method returns {@code void} and
17-
* nothing is sent back. Without a {@code @Cancel} method the SDK still records the cancel and runs
18-
* the grace period described below, but the prompt method is not told until the grace period
19-
* ends.
17+
* nothing is sent back. The prompt method does not need it to learn of the cancel: its context
18+
* says so ({@code SyncPromptContext.isCancelled()} or {@code onCancel(Runnable)},
19+
* {@code PromptContext.whenCancelled()}), for {@code session/cancel} and for a
20+
* {@code $/cancel_request} naming the prompt. Declare a {@code @Cancel} method for work outside
21+
* the prompt method, such as stopping a job the prompt started elsewhere.
2022
*
2123
* <p>The cancel does not end the turn. The prompt method should stop, send any last updates, and
2224
* return stop reason {@code cancelled}; until it returns, a new prompt on the session is answered
@@ -26,41 +28,37 @@
2628
* is SDK policy; ACP sets no time limit.
2729
*
2830
* <p>This method runs on a handler thread while the prompt method is still running on another, so
29-
* the two share a thread-safe flag, keyed by session id, as in the example. An exception it throws
30-
* is logged and dropped. {@code $/cancel_request}, which cancels a single request, never reaches
31+
* state the two share is thread-safe and keyed by session id, as in the example. An exception it
32+
* throws is logged and dropped. {@code $/cancel_request}, which cancels a single request, never reaches
3133
* this method: the SDK handles it.
3234
*
3335
* <p>The method can take a {@code CancelNotification}, a {@link SessionId @SessionId}
3436
* {@code String} and the connection parameters (see {@link AcpAgent}).
3537
*
36-
* <p>Example usage:
38+
* <p>Example usage, stopping a build the prompt started on a build server:
3739
* <pre>{@code
38-
* private final Map<String, AtomicBoolean> stopFlags = new ConcurrentHashMap<>();
40+
* private final Map<String, String> runningBuilds = new ConcurrentHashMap<>();
3941
*
40-
* @Prompt
41-
* public PromptResponse prompt(@SessionId String sessionId, SyncPromptContext context) {
42-
* AtomicBoolean stop = new AtomicBoolean();
43-
* stopFlags.put(sessionId, stop);
44-
* try {
45-
* for (int step = 1; step <= 3; step++) {
46-
* if (stop.get()) {
47-
* return new PromptResponse(StopReason.CANCELLED);
48-
* }
49-
* context.sendMessage("Step " + step + " done. ");
50-
* }
51-
* return PromptResponse.endTurn();
52-
* }
53-
* finally {
54-
* stopFlags.remove(sessionId);
42+
* @Cancel
43+
* public void cancel(@SessionId String sessionId) {
44+
* String buildId = runningBuilds.remove(sessionId);
45+
* if (buildId != null) {
46+
* buildServer.abort(buildId);
5547
* }
5648
* }
49+
* }</pre>
5750
*
58-
* @Cancel
59-
* public void cancel(@SessionId String sessionId) {
60-
* AtomicBoolean stop = stopFlags.get(sessionId);
61-
* if (stop != null) {
62-
* stop.set(true);
51+
* <p>A prompt method that only needs to stop its own loop checks its context instead:
52+
* <pre>{@code
53+
* @Prompt
54+
* public PromptResponse prompt(SyncPromptContext context) {
55+
* for (int step = 1; step <= 3; step++) {
56+
* if (context.isCancelled()) {
57+
* return PromptResponse.cancelled();
58+
* }
59+
* context.sendMessage("Step " + step + " done. ");
6360
* }
61+
* return PromptResponse.endTurn();
6462
* }
6563
* }</pre>
6664
*

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,18 @@
4646
*
4747
* <p>A session has one prompt turn at a time. Until this method returns, a second prompt on the
4848
* same session is answered with {@code -32600} (Invalid request); prompts on other sessions run at
49-
* the same time, on other threads. When the client cancels the turn, this method keeps running:
50-
* the {@link Cancel} method is called, and this method should stop and return stop reason
51-
* {@code cancelled}. If it has not returned when the cancel grace period ends, the SDK answers
52-
* {@code cancelled} for it and interrupts its thread. {@code AcpAgentSupport.Builder} sets the
53-
* grace period and can also limit how long any turn runs ({@code maxPromptDuration}, off by
54-
* default).
49+
* the same time, on other threads.
50+
*
51+
* <p><b>Cancellation.</b> The context tells the method that its turn was cancelled, by
52+
* {@code session/cancel} for its session or by {@code $/cancel_request} for its request: a sync
53+
* method polls {@code SyncPromptContext.isCancelled()} (or registers {@code onCancel(Runnable)}),
54+
* a method returning {@code Mono} composes {@code PromptContext.whenCancelled()}. After
55+
* {@code session/cancel} the method keeps running until it returns: it should stop, send any last
56+
* updates, and return {@code PromptResponse.cancelled()}. If it has not returned when the cancel
57+
* grace period ends, the SDK answers {@code cancelled} for it and interrupts its thread. After
58+
* {@code $/cancel_request} the SDK has already answered, so the method just stops.
59+
* {@code AcpAgentSupport.Builder} sets the grace period and can also limit how long any turn runs
60+
* ({@code maxPromptDuration}, off by default).
5561
*
5662
* <p>Example usage:
5763
* <pre>{@code

0 commit comments

Comments
 (0)