Skip to content

Commit 9e58ca9

Browse files
author
Mark Pollack
committed
Answer a prompt the handler does not: cancel grace period and maximum duration
Two limits on a session/prompt, configurable on the agent builders (AcpAgent.async/sync and AcpAgentSupport) and as PromptTimeouts on the AcpAgentSession constructor: - cancelGracePeriod, default 60 s: if the handler has not answered that long after session/cancel, the session cancels the handler's subscription and answers stopReason cancelled itself, which ACP v1 requires of a cancelled prompt and which ends the turn. - maxPromptDuration, off by default: when a prompt runs that long, the session cancels the handler and answers JSON-RPC error -32800 (request cancelled, which ACP v1 Cancellation asks of an internally cancelled request such as an internal timeout), or cancelled when the prompt was already being cancelled. Duration.ZERO turns either off; negative or null is rejected. The forced answer takes the normal path: the turn is released just before it is published, and it is queued behind every update the handler had sent. The timers run on the shared timeout timer and deliver on its delivery threads; no new threads. PromptAnswer decides who answers in one atomic step, so the handler's answer racing a timer gives exactly one response. PromptAnswerLincheckTest model checks the handler, a cancel, the grace timer and the max timer (at most one answer, turn released once answered) and a cancel racing the deadlines' set-up (the grace period still starts, once); both fail when the guard or the re-check is removed. The behaviour tests run on a virtual clock and failed with the deadlines switched off; an end-to-end test shows the forced answer arriving after the handler's update.
1 parent 700807e commit 9e58ca9

19 files changed

Lines changed: 1105 additions & 20 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
CVE-2026-91776, CVE-2026-91777 (both lines) and CVE-2026-19032, CVE-2026-68497, CVE-2026-83557 (Jackson 3).
1414
0.18.0 shipped with the affected versions; applications can override the versions now.
1515

16+
### Added
17+
18+
- **Prompt timeouts on the agent builders** (`AcpAgent.async(...)`, `AcpAgent.sync(...)` and
19+
`AcpAgentSupport.builder()`), also as `PromptTimeouts` on the `AcpAgentSession` constructor:
20+
- `cancelGracePeriod(Duration)`: how long a prompt handler has to answer after `session/cancel`
21+
before the agent answers `cancelled` itself. Default 60 seconds (see Changed).
22+
- `maxPromptDuration(Duration)`: how long a prompt may run at all. When it passes, the agent
23+
cancels the handler and answers with JSON-RPC error `-32800` (request cancelled), message
24+
`Prompt exceeded maxPromptDuration of <duration>` and data `{"maxPromptDuration": "<ISO-8601>"}`
25+
(ACP v1, Cancellation: an internally cancelled request, an internal timeout included, answers
26+
`-32800`); a prompt already being cancelled is answered `cancelled` instead. Default: none, as
27+
a prompt turn can legitimately run for a long time.
28+
29+
`Duration.ZERO` turns either off; a negative or null duration is rejected. Either way the forced
30+
answer leaves through the normal path: the turn ends just before it is published, it follows
31+
every update the handler had already sent, and exactly one answer is sent when the handler
32+
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.
36+
1637
### Changed
1738

1839
- **Behaviour change: `session/cancel` no longer ends the prompt turn; the cancelled prompt's
@@ -24,11 +45,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2445
`session/prompt`". A prompt sent between the cancel and that answer is now rejected with
2546
`-32000` (`CONCURRENT_PROMPT`), like any prompt during an active turn. The turn ends when the
2647
answer is published (before it reaches the client, as for every prompt), when the handler fails
27-
(a timeout the handler applies included), when the request is cancelled, or when the session
28-
closes. The session sets no timeout of its own on an inbound prompt, so a handler that never
29-
answers after a cancel keeps its session busy. Migration: clients send the next prompt after the
30-
cancelled one has answered (the SDK client sends nothing on its own after `cancel`); agent
31-
prompt handlers must answer a cancelled prompt.
48+
(a timeout the handler applies included), when the request is cancelled, when the session
49+
closes, or when the cancel grace period passes (below). Migration: clients send the next prompt
50+
after the cancelled one has answered (the SDK client sends nothing on its own after `cancel`);
51+
agent prompt handlers must answer a cancelled prompt.
52+
- **New default: a cancelled prompt is answered within 60 seconds.** If the prompt handler has not
53+
answered 60 seconds after `session/cancel`, the agent cancels the handler's subscription and
54+
answers the prompt with stop reason `cancelled` itself (the answer ACP v1 requires of a
55+
cancelled prompt), which ends the turn. Before, a handler that never answered a cancelled prompt
56+
kept its session busy for good. `cancelGracePeriod(Duration.ZERO)` restores that. The public
57+
four-argument `AcpAgentSession` constructor uses the same default.
3258
- **Breaking (unstable providers API): the provider identifier is `providerId`, on the wire and in
3359
Java.** `ProviderInfo`, `SetProviderRequest` and `DisableProviderRequest` wrote and read `"id"`,
3460
but the unstable schema names the property `providerId`, so no spec-conforming peer could exchange

‎acp-agent-support/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ This module transitively includes `acp-annotations` and `acp-core`.
6262
| `@LoadSession` | `session/load` | Loads an existing session by ID. |
6363
| `@Prompt` | `session/prompt` | Handles user prompts within a session. |
6464
| `@SetSessionMode` | `session/set_mode` | Changes the operational mode of a session. |
65-
| `@Cancel` | `session/cancel` | Handles cancellation notifications (fire-and-forget). The cancel does not end the prompt turn: the cancelled `@Prompt` method still returns, with stop reason `cancelled`, and the session rejects a new prompt until it has. |
65+
| `@Cancel` | `session/cancel` | Handles cancellation notifications (fire-and-forget). The cancel does not end the prompt turn: the cancelled `@Prompt` method still returns, with stop reason `cancelled`, and the session rejects a new prompt until it has, or until the cancel grace period (default 60 s, `cancelGracePeriod`) passes and the agent answers `cancelled` itself. |
6666

6767
### Parameter Annotations
6868

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
import com.agentclientprotocol.sdk.spec.AcpSchema;
7575
import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse;
7676
import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse;
77+
import com.agentclientprotocol.sdk.spec.PromptTimeouts;
7778

7879
import org.jspecify.annotations.Nullable;
7980
import org.slf4j.Logger;
@@ -135,7 +136,9 @@ private AcpAgentSupport(Builder builder, AcpAgentTransport transport) {
135136

136137
// Build the underlying sync agent
137138
var agentBuilder = AcpAgent.sync(transport)
138-
.requestTimeout(builder.requestTimeout);
139+
.requestTimeout(builder.requestTimeout)
140+
.cancelGracePeriod(builder.cancelGracePeriod)
141+
.maxPromptDuration(builder.maxPromptDuration);
139142

140143
// Wire discovered handlers to the agent builder
141144
wireHandlers(agentBuilder);
@@ -392,6 +395,10 @@ public static class Builder {
392395

393396
private Duration requestTimeout = Duration.ofSeconds(30);
394397

398+
private Duration cancelGracePeriod = PromptTimeouts.DEFAULT_CANCEL_GRACE_PERIOD;
399+
400+
private Duration maxPromptDuration = Duration.ZERO;
401+
395402
/**
396403
* Register an agent instance.
397404
* @param agentInstance the annotated agent instance
@@ -454,6 +461,31 @@ public Builder requestTimeout(Duration timeout) {
454461
return this;
455462
}
456463

464+
/**
465+
* 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
468+
* none. See {@code AcpAgent.SyncAgentBuilder#cancelGracePeriod}.
469+
* @param gracePeriod the grace period; zero for none, not negative
470+
* @return this builder
471+
*/
472+
public Builder cancelGracePeriod(Duration gracePeriod) {
473+
this.cancelGracePeriod = gracePeriod;
474+
return this;
475+
}
476+
477+
/**
478+
* Set how long a prompt may run before the agent answers it with error
479+
* {@code -32800} (request cancelled). Default none ({@link Duration#ZERO}). See
480+
* {@code AcpAgent.SyncAgentBuilder#maxPromptDuration}.
481+
* @param maxDuration the maximum prompt duration; zero for none, not negative
482+
* @return this builder
483+
*/
484+
public Builder maxPromptDuration(Duration maxDuration) {
485+
this.maxPromptDuration = maxDuration;
486+
return this;
487+
}
488+
457489
/**
458490
* Add an interceptor.
459491
* @param interceptor the interceptor

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
*
2222
* <p>The cancel does not end the prompt turn: the cancelled prompt handler should stop,
2323
* send any last updates, and return stop reason {@code cancelled}. Until it has returned
24-
* (or failed), the session rejects a new prompt (ACP v1, prompt turn, Cancellation).
24+
* (or failed), the session rejects a new prompt (ACP v1, prompt turn, Cancellation). If it
25+
* 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.
2528
*
2629
* <p>The method can have the following parameters (all optional):
2730
* <ul>

‎acp-core/pom.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@
221221
<param>com.agentclientprotocol.sdk.spec.OutboundMessages*</param>
222222
<param>com.agentclientprotocol.sdk.spec.PendingResponses*</param>
223223
<param>com.agentclientprotocol.sdk.spec.ActivePrompts*</param>
224+
<param>com.agentclientprotocol.sdk.spec.Prompt*</param>
224225
<param>com.agentclientprotocol.sdk.spec.InboundMessages*</param>
225226
<param>com.agentclientprotocol.sdk.spec.ResponseResults*</param>
226227
<param>com.agentclientprotocol.sdk.spec.AcpError*</param>

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

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import com.agentclientprotocol.sdk.json.TypeRef;
1414
import com.agentclientprotocol.sdk.spec.AcpAgentTransport;
1515
import com.agentclientprotocol.sdk.spec.AcpSchema;
16+
import com.agentclientprotocol.sdk.spec.PromptTimeouts;
1617
import com.agentclientprotocol.sdk.util.Assert;
1718
import org.slf4j.Logger;
1819
import org.slf4j.LoggerFactory;
@@ -321,7 +322,9 @@ interface DisableProviderHandler {
321322
* A cancel does not end the prompt turn: stop the prompt's work, send any last
322323
* updates, and answer the prompt with stop reason {@code cancelled}. The session stays
323324
* busy, rejecting a new prompt, until that answer (or an error) is sent (ACP v1, prompt
324-
* turn, Cancellation).
325+
* turn, Cancellation). If the prompt handler has not answered within the cancel grace
326+
* period (60 seconds unless set with {@code cancelGracePeriod}), the agent cancels it
327+
* and answers {@code cancelled} itself.
325328
* </p>
326329
*/
327330
@FunctionalInterface
@@ -554,6 +557,8 @@ class AsyncAgentBuilder {
554557

555558
private Duration requestTimeout = DEFAULT_REQUEST_TIMEOUT;
556559

560+
private PromptTimeouts promptTimeouts = PromptTimeouts.DEFAULTS;
561+
557562
private final AgentHandlers handlers = new AgentHandlers();
558563

559564
AsyncAgentBuilder(AcpAgentTransport transport) {
@@ -572,6 +577,37 @@ public AsyncAgentBuilder requestTimeout(Duration timeout) {
572577
return this;
573578
}
574579

580+
/**
581+
* Sets how long a prompt handler has to answer after {@code session/cancel}. When it
582+
* passes, the agent cancels the handler and answers the prompt itself with stop
583+
* reason {@code cancelled}, as ACP requires of a cancelled prompt, which ends the
584+
* turn so the session accepts a new prompt. Updates the handler sent before that
585+
* answer reach the client first. Default: 60 seconds
586+
* ({@link PromptTimeouts#DEFAULT_CANCEL_GRACE_PERIOD}); {@link Duration#ZERO} turns
587+
* it off, and a handler that never answers then keeps its session busy.
588+
* @param gracePeriod the grace period; zero for none, not negative
589+
* @return This builder for chaining
590+
*/
591+
public AsyncAgentBuilder cancelGracePeriod(Duration gracePeriod) {
592+
this.promptTimeouts = this.promptTimeouts.withCancelGracePeriod(gracePeriod);
593+
return this;
594+
}
595+
596+
/**
597+
* Sets how long a prompt may run. When it passes, the agent cancels the handler and
598+
* answers the prompt with JSON-RPC error {@code -32800} (request cancelled; ACP
599+
* answers an internally cancelled request, an internal timeout included, with this
600+
* code), or with stop reason {@code cancelled} if the client had cancelled the
601+
* prompt, and the turn ends. Default: none ({@link Duration#ZERO}), since a prompt
602+
* turn can legitimately run for a long time.
603+
* @param maxDuration the maximum prompt duration; zero for none, not negative
604+
* @return This builder for chaining
605+
*/
606+
public AsyncAgentBuilder maxPromptDuration(Duration maxDuration) {
607+
this.promptTimeouts = this.promptTimeouts.withMaxPromptDuration(maxDuration);
608+
return this;
609+
}
610+
575611
/**
576612
* Sets the handler for initialize requests.
577613
* @param handler The initialize handler
@@ -757,7 +793,7 @@ private <T> AsyncAgentBuilder request(String method, TypeRef<T> requestType,
757793
* @return A new AcpAsyncAgent instance
758794
*/
759795
public AcpAsyncAgent build() {
760-
return new DefaultAcpAsyncAgent(transport, requestTimeout, handlers);
796+
return new DefaultAcpAsyncAgent(transport, requestTimeout, promptTimeouts, handlers);
761797
}
762798

763799
}
@@ -801,6 +837,37 @@ public SyncAgentBuilder requestTimeout(Duration timeout) {
801837
return this;
802838
}
803839

840+
/**
841+
* 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
843+
* reason {@code cancelled}, as ACP requires of a cancelled prompt, which ends the
844+
* turn so the session accepts a new prompt. Updates the handler sent before that
845+
* answer reach the client first. Default: 60 seconds
846+
* ({@link PromptTimeouts#DEFAULT_CANCEL_GRACE_PERIOD}); {@link Duration#ZERO} turns
847+
* it off, and a handler that never answers then keeps its session busy.
848+
* @param gracePeriod the grace period; zero for none, not negative
849+
* @return This builder for chaining
850+
*/
851+
public SyncAgentBuilder cancelGracePeriod(Duration gracePeriod) {
852+
asyncBuilder.cancelGracePeriod(gracePeriod);
853+
return this;
854+
}
855+
856+
/**
857+
* Sets how long a prompt may run. When it passes, the agent cancels the handler and
858+
* answers the prompt with JSON-RPC error {@code -32800} (request cancelled; ACP
859+
* answers an internally cancelled request, an internal timeout included, with this
860+
* code), or with stop reason {@code cancelled} if the client had cancelled the
861+
* prompt, and the turn ends. Default: none ({@link Duration#ZERO}), since a prompt
862+
* turn can legitimately run for a long time.
863+
* @param maxDuration the maximum prompt duration; zero for none, not negative
864+
* @return This builder for chaining
865+
*/
866+
public SyncAgentBuilder maxPromptDuration(Duration maxDuration) {
867+
asyncBuilder.maxPromptDuration(maxDuration);
868+
return this;
869+
}
870+
804871
/**
805872
* Sets the synchronous handler for initialize requests.
806873
* @param handler The sync initialize handler (returns plain value)

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import com.agentclientprotocol.sdk.spec.AcpAgentSession;
1717
import com.agentclientprotocol.sdk.spec.AcpAgentTransport;
1818
import com.agentclientprotocol.sdk.spec.AcpSchema;
19+
import com.agentclientprotocol.sdk.spec.PromptTimeouts;
1920
import com.agentclientprotocol.sdk.json.TypeRef;
2021
import org.jspecify.annotations.Nullable;
2122
import org.slf4j.Logger;
@@ -42,6 +43,8 @@ class DefaultAcpAsyncAgent implements AcpAsyncAgent {
4243

4344
private final Duration requestTimeout;
4445

46+
private final PromptTimeouts promptTimeouts;
47+
4548
private final List<AgentHandlers.Request<?>> requestHandlers;
4649

4750
private final List<AgentHandlers.Notification<?>> notificationHandlers;
@@ -57,9 +60,11 @@ class DefaultAcpAsyncAgent implements AcpAsyncAgent {
5760
*/
5861
private final AtomicReference<@Nullable NegotiatedCapabilities> clientCapabilities = new AtomicReference<>();
5962

60-
DefaultAcpAsyncAgent(AcpAgentTransport transport, Duration requestTimeout, AgentHandlers handlers) {
63+
DefaultAcpAsyncAgent(AcpAgentTransport transport, Duration requestTimeout, PromptTimeouts promptTimeouts,
64+
AgentHandlers handlers) {
6165
this.transport = transport;
6266
this.requestTimeout = requestTimeout;
67+
this.promptTimeouts = promptTimeouts;
6368
this.requestHandlers = handlers.requests();
6469
this.notificationHandlers = handlers.notifications();
6570
}
@@ -76,7 +81,7 @@ public Mono<Void> start() {
7681
for (AgentHandlers.Notification<?> registration : notificationHandlers) {
7782
notifications.put(registration.method(), sessionHandler(registration));
7883
}
79-
this.session = new AcpAgentSession(requestTimeout, transport, requests, notifications);
84+
this.session = new AcpAgentSession(requestTimeout, transport, requests, notifications, promptTimeouts);
8085
logger.info("ACP async agent started");
8186
});
8287
}

‎acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpErrorCodes.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ private AcpErrorCodes() {
5656
*/
5757
public static final int INTERNAL_ERROR = -32603;
5858

59+
/**
60+
* Request cancelled: execution of the method was aborted, because the caller cancelled
61+
* the request or because of resource constraints or shutdown. ACP answers an internally
62+
* cancelled request (an internal timeout, for one) with this code too.
63+
*/
64+
public static final int REQUEST_CANCELLED = -32800;
65+
5966
// --------------------------
6067
// ACP-Specific Errors
6168
// --------------------------
@@ -94,6 +101,7 @@ private AcpErrorCodes() {
94101
private static final Map<Integer, String> DESCRIPTIONS = Map.ofEntries(Map.entry(PARSE_ERROR, "Parse error"),
95102
Map.entry(INVALID_REQUEST, "Invalid request"), Map.entry(METHOD_NOT_FOUND, "Method not found"),
96103
Map.entry(INVALID_PARAMS, "Invalid params"), Map.entry(INTERNAL_ERROR, "Internal error"),
104+
Map.entry(REQUEST_CANCELLED, "Request cancelled"),
97105
Map.entry(CONCURRENT_PROMPT, "Concurrent prompt"),
98106
Map.entry(CAPABILITY_NOT_SUPPORTED, "Capability not supported"),
99107
Map.entry(SESSION_NOT_FOUND, "Session not found"), Map.entry(NOT_INITIALIZED, "Not initialized"),

0 commit comments

Comments
 (0)