Skip to content

Commit 08732ad

Browse files
author
Mark Pollack
committed
Drop prompt context updates once the prompt has been answered
ACP requires a prompt's session/update notifications to precede its answer, also after session/cancel. A handler still running after the SDK answered the prompt (cancel grace period or maxPromptDuration), or one sending after its own answer, sent updates after the answer. The prompt contexts now ask the prompt's turn, through the answered-once state that decides who answers, and drop such updates with a DEBUG log.
1 parent b9b4978 commit 08732ad

13 files changed

Lines changed: 208 additions & 19 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
241241
timers run on the SDK's shared timeout timer, with no new threads. Cancelling a sync handler
242242
interrupts its thread (the SDK's sync handler scheduler interrupts on cancel); a handler that
243243
ignores the interrupt, or any handler that keeps running after its subscription is cancelled,
244-
and sends more updates sends them after the answer. `AcpErrorCodes.REQUEST_CANCELLED` (`-32800`) is new.
244+
has the updates it then sends through its prompt context dropped (see Fixed). `AcpErrorCodes.REQUEST_CANCELLED` (`-32800`) is new.
245245
- **`session_info_update`** (`AcpSchema.SessionInfoUpdate`): the agent tells the client the
246246
session's title and last activity time. Stable in ACP v1. Known limit: the schema lets a peer
247247
send `null` to clear a field; the record reads an explicit `null` like a missing field and never
@@ -1043,6 +1043,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10431043

10441044
### Fixed
10451045

1046+
- **A prompt's updates no longer follow its answer.** ACP requires an agent to send a prompt's
1047+
`session/update` notifications before it answers the prompt, also after `session/cancel`. When
1048+
the SDK answered a prompt itself (the cancel grace period or `maxPromptDuration` passed), a
1049+
handler that kept running could still send updates through its prompt context, and they went out
1050+
after the answer; so could a handler that sent through its context after returning its own
1051+
answer. Once a prompt has been answered, by its handler or by the SDK, `PromptContext.sendUpdate`
1052+
and `SyncPromptContext.sendUpdate` (and the helpers built on them, such as `sendMessage`) drop
1053+
the update and log it at DEBUG, without its content; the `Mono` completes empty. The check uses
1054+
the prompt's answered-once state that decides who answers. Updates sent with
1055+
`sendSessionUpdate` outside a prompt context are not affected.
1056+
10461057
- **A builder agent advertises `providers` for any provider handler.** Without an initialize
10471058
handler, a builder agent advertised `providers` only for a `providers/list` handler, while an
10481059
annotated agent does for any of the three provider methods; with only a set or disable handler

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,8 +1120,9 @@ public SyncAgentBuilder requestTimeout(Duration timeout) {
11201120

11211121
/**
11221122
* Sets how long a prompt handler has to answer after {@code session/cancel}. When it
1123-
* passes, the agent cancels the handler, which interrupts its thread if it is blocked (what
1124-
* it sends if it keeps running is its own), and answers the prompt itself with stop reason
1123+
* passes, the agent cancels the handler, which interrupts its thread if it is blocked (updates
1124+
* it sends through its prompt context if it keeps running are dropped), and answers the
1125+
* prompt itself with stop reason
11251126
* {@code cancelled}, as ACP requires of a cancelled prompt; that ends the turn, so the
11261127
* session accepts a new prompt. Updates the handler sent before that answer reach the
11271128
* client first. Default: 60 seconds ({@link PromptTimeouts#DEFAULT_CANCEL_GRACE_PERIOD});

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.util.List;
1010
import java.util.Map;
1111
import java.util.concurrent.atomic.AtomicReference;
12+
import java.util.function.BooleanSupplier;
1213
import java.util.function.Predicate;
1314

1415
import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities;
@@ -149,6 +150,15 @@ PromptCancellations.Signal promptSignal(String sessionId) {
149150
return (signal != null) ? signal : new PromptCancellations.Signal();
150151
}
151152

153+
/**
154+
* Whether the prompt running on {@code sessionId} has been answered; always false when no
155+
* prompt is running (a context built outside a running prompt).
156+
*/
157+
BooleanSupplier promptAnswered(String sessionId) {
158+
AcpAgentSession current = this.session;
159+
return (current != null) ? current.promptAnswered(sessionId) : () -> false;
160+
}
161+
152162
private <T> AcpAgentSession.NotificationHandler sessionHandler(AgentHandlers.Notification<T> registration) {
153163
return params -> registration.handler()
154164
.apply(transport.unmarshalParams(params, registration.notificationType()));

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.util.Map;
1010
import java.util.UUID;
1111
import java.util.concurrent.atomic.AtomicBoolean;
12+
import java.util.function.BooleanSupplier;
1213

1314
import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities;
1415
import com.agentclientprotocol.sdk.spec.AcpError;
@@ -58,6 +59,9 @@ class DefaultPromptContext implements PromptContext {
5859

5960
private final PromptCancellations.Signal cancellation;
6061

62+
/** Whether this context's prompt has been answered: its updates are then dropped. */
63+
private final BooleanSupplier answered;
64+
6165
/**
6266
* Creates a new prompt context wrapping the given agent.
6367
* @param agent The agent to delegate to
@@ -68,6 +72,8 @@ class DefaultPromptContext implements PromptContext {
6872
this.sessionId = sessionId;
6973
this.cancellation = (agent instanceof DefaultAcpAsyncAgent running) ? running.promptSignal(sessionId)
7074
: new PromptCancellations.Signal();
75+
this.answered = (agent instanceof DefaultAcpAsyncAgent running) ? running.promptAnswered(sessionId)
76+
: () -> false;
7177
}
7278

7379
// ========================================================================
@@ -76,7 +82,15 @@ class DefaultPromptContext implements PromptContext {
7682

7783
@Override
7884
public Mono<Void> sendUpdate(AcpSchema.SessionUpdate update) {
79-
return agent.sendSessionUpdate(sessionId, update);
85+
return Mono.defer(() -> {
86+
if (this.answered.getAsBoolean()) {
87+
// ACP: a prompt's session/update notifications must precede its answer.
88+
logger.debug("Dropped a {} update for session {}: its prompt has been answered",
89+
update.getClass().getSimpleName(), sessionId);
90+
return Mono.empty();
91+
}
92+
return agent.sendSessionUpdate(sessionId, update);
93+
});
8094
}
8195

8296
@Override
@@ -213,14 +227,13 @@ private Mono<AcpSchema.RequestPermissionResponse> ask(String title, ToolKind kin
213227
ToolCall announce = new ToolCall("tool_call", toolCallId, title, null, kind, ToolCallStatus.PENDING, null, null,
214228
null, null, null);
215229
ToolCallUpdate toolCall = new ToolCallUpdate(toolCallId, title, kind, ToolCallStatus.PENDING);
216-
return agent.sendSessionUpdate(sessionId, announce)
230+
return sendUpdate(announce)
217231
.then(requestPermission(new RequestPermissionRequest(sessionId, toolCall, options)))
218232
.flatMap(response -> {
219233
ToolCallStatus status = (response.outcome() instanceof PermissionSelected) ? ToolCallStatus.COMPLETED
220234
: ToolCallStatus.FAILED;
221-
return agent
222-
.sendSessionUpdate(sessionId, new ToolCallUpdateNotification("tool_call_update", toolCallId, null, null, null,
223-
status, null, null, null, null, null))
235+
return sendUpdate(new ToolCallUpdateNotification("tool_call_update", toolCallId, null, null, null,
236+
status, null, null, null, null, null))
224237
.thenReturn(response);
225238
});
226239
});

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@
4343
* agent's request timeout fails it with a {@link java.util.concurrent.TimeoutException}. When the
4444
* SDK cancels the handler (after the cancel grace period, or for a {@code $/cancel_request}), it
4545
* disposes the handler's {@code Mono}: requests still waiting inside it are cancelled, and the
46-
* client is sent a {@code $/cancel_request} for each. The context does not check that its turn is
47-
* still active. Its methods may be called from several threads at once.
46+
* client is sent a {@code $/cancel_request} for each. Once the prompt has been answered, its
47+
* updates are dropped (see {@link #sendUpdate}); its other calls are not checked against the
48+
* turn. Its methods may be called from several threads at once.
4849
*
4950
* <p>Implementations: the SDK supplies the context handlers receive; implement this interface only
5051
* for test doubles.
@@ -64,7 +65,10 @@ public interface PromptContext {
6465
* Sends a {@code session/update} notification to the client, carrying one
6566
* {@link AcpSchema.SessionUpdate}: a message or thought chunk, a tool call or its update, a
6667
* plan, and so on, for this prompt's session ({@link #getSessionId()}). The Java client hands a
67-
* turn's updates to its consumers in order, before the prompt's answer. To update another
68+
* turn's updates to its consumers in order, before the prompt's answer. Once the prompt has
69+
* been answered, by the handler or by the SDK when a prompt deadline passed, the SDK's context
70+
* drops further updates (logged at DEBUG) and the {@code Mono} completes empty: ACP requires a
71+
* prompt's updates to precede its answer. To update another
6872
* session, use {@link AcpAsyncAgent#sendSessionUpdate(String, AcpSchema.SessionUpdate)}.
6973
* @param update the update
7074
* @return a {@code Mono} that completes when the notification has been handed to the transport

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
* {@link java.util.concurrent.TimeoutException}. When the SDK cancels the handler (after the
4545
* cancel grace period, or for a {@code $/cancel_request}), it interrupts the handler's thread, and
4646
* a call blocked at that moment throws {@link java.util.concurrent.CancellationException} and
47-
* leaves the thread's interrupt flag set. The context does not check that its turn is still active.
47+
* leaves the thread's interrupt flag set. Once the prompt has been answered, its updates are
48+
* dropped (see {@link #sendUpdate}); its other calls are not checked against the turn.
4849
*
4950
* <p>Implementations: the SDK supplies the context handlers receive; implement this interface only
5051
* for test doubles, and include {@link #async()}.
@@ -65,7 +66,10 @@ public interface SyncPromptContext {
6566
* {@link AcpSchema.SessionUpdate}: a message or thought chunk, a tool call or its update, a
6667
* plan, and so on, for this prompt's session ({@link #getSessionId()}). Returns once the
6768
* notification has been handed to the transport. The Java client hands a turn's updates to its
68-
* consumers in order, before the prompt's answer. To update another session, use
69+
* consumers in order, before the prompt's answer. Once the prompt has been answered, by the
70+
* handler or by the SDK when a prompt deadline passed, the SDK's context drops further
71+
* updates (logged at DEBUG) and returns at once: ACP requires a prompt's updates to precede
72+
* its answer. To update another session, use
6973
* {@link AcpSyncAgent#sendSessionUpdate(String, AcpSchema.SessionUpdate)}.
7074
* @param update the update
7175
*/

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import java.time.Duration;
88
import java.util.Map;
99
import java.util.Set;
10+
import java.util.function.BooleanSupplier;
1011
import java.util.function.Function;
1112
import java.util.concurrent.ConcurrentHashMap;
1213
import java.util.concurrent.atomic.AtomicReference;
@@ -414,6 +415,24 @@ public boolean hasActivePrompt(String sessionId) {
414415
return activePrompts.isActive(sessionId);
415416
}
416417

418+
/**
419+
* Whether the prompt now running on {@code sessionId} has been answered, by its handler or by
420+
* the session when a prompt deadline passed, as the session sees it later: the SDK's prompt
421+
* contexts ask it before each update, since ACP requires a prompt's updates to precede its
422+
* answer. The answer is about the prompt running at this call; when none is, it is always
423+
* {@code false}.
424+
* @param sessionId the logical ACP session ID
425+
* @return a check, true once that prompt has been answered
426+
*/
427+
public BooleanSupplier promptAnswered(String sessionId) {
428+
Assert.hasText(sessionId, "The sessionId can not be empty");
429+
ActivePrompts.Turn turn = activePrompts.current(sessionId);
430+
if (turn == null) {
431+
return () -> false;
432+
}
433+
return turn::isAnswered;
434+
}
435+
417436
/**
418437
* Gets one active prompt session ID, if any.
419438
*

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1689,9 +1689,9 @@ public RequestPermissionResponse(RequestPermissionOutcome outcome) {
16891689
*
16901690
* <p>
16911691
* The protocol requires an agent to send a turn's updates before it answers the prompt, also
1692-
* after a {@code session/cancel}. When the SDK answers a prompt itself, because the cancel
1693-
* grace period or the maximum prompt duration passed, a prompt handler that keeps running and
1694-
* sends more updates sends them after that answer (see {@link PromptTimeouts}).
1692+
* after a {@code session/cancel}. The SDK's prompt contexts drop the updates a handler sends
1693+
* once its prompt has been answered, by the handler or by the SDK when the cancel grace period
1694+
* or the maximum prompt duration passed (see {@link PromptTimeouts}).
16951695
*
16961696
* <p>
16971697
* The Java client skips, with a warning in the log, a received notification it cannot read or

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ static final class Turn {
5454
/** Completes when the turn ends and releases its session. */
5555
private final Sinks.Empty<Void> ended = Sinks.empty();
5656

57+
/** Set when the turn ends, before its answer is published. */
58+
private volatile boolean over;
59+
5760
/** Runs when a cancel arrives; set by {@link PromptDeadlines} to start the grace period. */
5861
private volatile @Nullable Runnable onCancelRequested;
5962

@@ -66,6 +69,14 @@ PromptAnswer answer() {
6669
return this.answer;
6770
}
6871

72+
/**
73+
* Whether the prompt has been answered: a deadline claimed the answer, or the turn ended,
74+
* which it does just before any answer is published.
75+
*/
76+
boolean isAnswered() {
77+
return this.over || this.answer.isAnswered();
78+
}
79+
6980
/**
7081
* Runs {@code action} when a cancel arrives for this prompt, or now if one already
7182
* has. It may run twice when the two race: it must be idempotent.
@@ -142,6 +153,7 @@ <T> Mono<T> endBeforePublishing(Turn turn, Mono<T> response) {
142153
* @return whether this call released the session
143154
*/
144155
boolean end(Turn turn, String reason) {
156+
turn.over = true;
145157
if (this.active.remove(turn.sessionId(), turn)) {
146158
logger.debug("Prompt lock released for sessionId={} requestId={} ({})", turn.sessionId(),
147159
turn.requestId(), reason);

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ boolean isCancelling() {
5252
return this.state.get() == State.CANCELLING;
5353
}
5454

55+
/** Whether the prompt has been answered, by its handler or by a deadline. */
56+
boolean isAnswered() {
57+
return this.state.get() == State.ANSWERED;
58+
}
59+
5560
/**
5661
* The handler answered (or failed).
5762
* @return whether its answer is the prompt's answer; false when a deadline answered first

0 commit comments

Comments
 (0)