Skip to content

Commit bc8b001

Browse files
author
Mark Pollack
committed
Extension methods from prompt handlers and agent-aware extension handlers
SessionClient and SyncSessionClient, the client() of a prompt context, gain sendExtRequest (typed and raw) and sendExtNotification, the agent's own calls, so a prompt handler can send _-prefixed requests and notifications to the client. extRequestHandler and extNotificationHandler on both agent builders gain agent-aware overloads: a two-argument lambda receives the params and the built agent, as the other B2 setters do. They live in AsyncAgentAwareSetters and SyncAgentAwareSetters, keeping AcpAgent under the PMD statement limit; internal notification handlers now receive the agent, as request handlers already did.
1 parent 96dbfbf commit bc8b001

14 files changed

Lines changed: 585 additions & 17 deletions

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
136136
`AcpSyncAgent agent = AcpAgent.sync(t).newSessionHandler((request, agent) -> ...)` does not
137137
compile, since a lambda parameter cannot shadow a local variable.
138138

139+
- **Extension methods from inside handlers.** `SessionClient` (`PromptContext.client()`) gains
140+
`sendExtRequest(String, Object, TypeRef<T>)`, `sendExtRequest(String, Object)` and
141+
`sendExtNotification(String, Object)`, and `SyncSessionClient` (`SyncPromptContext.client()`)
142+
their blocking forms, the same calls as `AcpAsyncAgent`/`AcpSyncAgent`, so a prompt handler can
143+
send `_`-prefixed requests and notifications to the client:
144+
`context.client().sendExtRequest("_example.com/ping", params, PONG)`. `extRequestHandler` and
145+
`extNotificationHandler` on both agent builders gain agent-aware overloads, typed and raw: a
146+
two-argument lambda receives the params and the agent `build()` returned
147+
(`AgentAwareHandler<T, R>` and `AgentAwareHandler<T, Void>` on the async builder,
148+
`SyncAgentAwareHandler<T, R>` and `BiConsumer<T, AcpSyncAgent>` on the sync one), for example
149+
`.extRequestHandler("_example.com/ask", ASK, (ask, self) -> self.sendExtRequest("_example.com/confirm", ask, ANSWER))`.
150+
Migration: none; a test double that implements `SessionClient` or `SyncSessionClient` adds the
151+
three methods.
152+
139153
- **Capability builders that lead to each choice.** `ClientCapabilities.builder()` gains
140154
`readTextFile()`, `writeTextFile()`, `terminal()`, `elicitationForm()` and `elicitationUrl()`;
141155
`AgentCapabilities.builder()` gains `loadSession()`, `promptImage()`, `promptAudio()`,

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,7 +1499,7 @@ public AsyncAgentBuilder disableProviderHandler(DisableProviderHandler handler)
14991499
public AsyncAgentBuilder cancelHandler(CancelHandler handler) {
15001500
Assert.notNull(handler, "Handler must not be null");
15011501
handlers.notification(AcpSchema.METHOD_SESSION_CANCEL, new TypeRef<AcpSchema.CancelNotification>() {
1502-
}, handler::handle);
1502+
}, (notification, agent) -> handler.handle(notification));
15031503
return this;
15041504
}
15051505

@@ -1557,8 +1557,7 @@ public <T> AsyncAgentBuilder extNotificationHandler(String method, TypeRef<T> pa
15571557
ExtensionMethods.requireExtension(method);
15581558
Assert.notNull(paramsType, "Params type must not be null");
15591559
Assert.notNull(handler, "Handler must not be null");
1560-
handlers.notification(method, paramsType, handler::handle);
1561-
return this;
1560+
return notification(method, paramsType, (params, agent) -> handler.handle(params));
15621561
}
15631562

15641563
/**
@@ -1581,6 +1580,13 @@ <T> AsyncAgentBuilder request(String method, TypeRef<T> requestType,
15811580
return this;
15821581
}
15831582

1583+
@Override
1584+
<T> AsyncAgentBuilder notification(String method, TypeRef<T> notificationType,
1585+
AgentHandlers.NotificationHandler<T> handler) {
1586+
handlers.notification(method, notificationType, handler);
1587+
return this;
1588+
}
1589+
15841590
/**
15851591
* Builds the agent on the builder's transport, with the handlers registered so far;
15861592
* handlers registered afterwards do not reach it. The agent does nothing until
@@ -2032,7 +2038,7 @@ public <T> SyncAgentBuilder extNotificationHandler(String method, TypeRef<T> par
20322038
SyncExtNotificationHandler<T> handler) {
20332039
Assert.notNull(handler, "Handler must not be null");
20342040
asyncBuilder.extNotificationHandler(method, paramsType,
2035-
params -> Mono.<Void>fromRunnable(HandlerFailures.guard(() -> handler.handle(params))).subscribeOn(this.handlerScheduler));
2041+
params -> runOnSyncHandlerThread(() -> handler.handle(params)));
20362042
return this;
20372043
}
20382044

@@ -2079,6 +2085,11 @@ <T> Mono<T> onSyncHandlerThread(Callable<T> handler) {
20792085
return Mono.fromCallable(HandlerFailures.guard(handler)).subscribeOn(this.handlerScheduler);
20802086
}
20812087

2088+
@Override
2089+
Mono<Void> runOnSyncHandlerThread(Runnable handler) {
2090+
return Mono.<Void>fromRunnable(HandlerFailures.guard(handler)).subscribeOn(this.handlerScheduler);
2091+
}
2092+
20822093
}
20832094

20842095
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
* or call the client. Every typed setter of {@link AcpAgent.AsyncAgentBuilder} except
1515
* {@code promptHandler} (whose {@link PromptContext} already reaches the client) has an
1616
* overload that takes one, for the same request and response types as its own handler
17-
* interface; a two-argument lambda picks it.
17+
* interface, and so do {@code extRequestHandler} and {@code extNotificationHandler} (an
18+
* {@code AgentAwareHandler<T, Void>}); a two-argument lambda picks it.
1819
*
1920
* <pre>{@code
2021
* AcpAsyncAgent agent = AcpAgent.async(transport)

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import java.util.LinkedHashMap;
88
import java.util.List;
99
import java.util.Map;
10-
import java.util.function.Function;
1110

1211
import com.agentclientprotocol.sdk.json.TypeRef;
1312
import com.agentclientprotocol.sdk.spec.AcpSchema;
@@ -46,6 +45,23 @@ interface RequestHandler<T> {
4645

4746
}
4847

48+
/**
49+
* Handles one inbound notification, already read into its type.
50+
* @param <T> the notification type
51+
*/
52+
@FunctionalInterface
53+
interface NotificationHandler<T> {
54+
55+
/**
56+
* Handles the notification.
57+
* @param notification the notification
58+
* @param agent the agent that received it, for handlers that act back on the client
59+
* @return a {@code Mono} that completes when the notification is handled
60+
*/
61+
Mono<Void> handle(T notification, AcpAsyncAgent agent);
62+
63+
}
64+
4965
/**
5066
* One request method: its name, the type its params are read as, and its handler.
5167
* @param <T> the request type
@@ -57,7 +73,7 @@ record Request<T>(String method, TypeRef<T> requestType, RequestHandler<T> handl
5773
* One notification method: its name, the type its params are read as, and its handler.
5874
* @param <T> the notification type
5975
*/
60-
record Notification<T>(String method, TypeRef<T> notificationType, Function<T, Mono<Void>> handler) {
76+
record Notification<T>(String method, TypeRef<T> notificationType, NotificationHandler<T> handler) {
6177
}
6278

6379
private final Map<String, Request<?>> requests = new LinkedHashMap<>();
@@ -70,7 +86,7 @@ <T> void request(String method, TypeRef<T> requestType, RequestHandler<T> handle
7086
}
7187
}
7288

73-
<T> void notification(String method, TypeRef<T> notificationType, Function<T, Mono<Void>> handler) {
89+
<T> void notification(String method, TypeRef<T> notificationType, NotificationHandler<T> handler) {
7490
if (notifications.putIfAbsent(method, new Notification<>(method, notificationType, handler)) != null) {
7591
throw alreadyRegistered(method, "extNotificationHandler");
7692
}

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

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
import com.agentclientprotocol.sdk.annotation.UnstableAcpApi;
88
import com.agentclientprotocol.sdk.json.TypeRef;
99
import com.agentclientprotocol.sdk.spec.AcpSchema;
10+
import com.agentclientprotocol.sdk.spec.ExtensionMethods;
1011
import com.agentclientprotocol.sdk.util.Assert;
1112

1213
/**
1314
* The setters of {@link AcpAgent.AsyncAgentBuilder} that take an {@link AgentAwareHandler}: one
14-
* overload of each typed request setter but {@code promptHandler}, registering a handler that also
15-
* receives the agent. Kept apart from the builder only to keep that class small; it is part of the
15+
* overload of each typed request setter but {@code promptHandler}, and of each extension handler
16+
* setter, registering a handler that also receives the agent. Kept apart from the builder only to keep that class small; it is part of the
1617
* builder's API.
1718
*/
1819
abstract class AsyncAgentAwareSetters<B> {
@@ -25,6 +26,10 @@ abstract class AsyncAgentAwareSetters<B> {
2526
abstract <T> B request(String method, TypeRef<T> requestType,
2627
AgentHandlers.RequestHandler<T> handler);
2728

29+
/** Registers the handler of one notification method. */
30+
abstract <T> B notification(String method, TypeRef<T> notificationType,
31+
AgentHandlers.NotificationHandler<T> handler);
32+
2833
/**
2934
* Sets the handler for {@code initialize}, as {@link AcpAgent.AsyncAgentBuilder#initializeHandler(AcpAgent.InitializeHandler)} does, for a handler
3035
* that also needs the agent it serves, for example to send session updates
@@ -239,4 +244,79 @@ public B disableProviderHandler(
239244
}, handler::handle);
240245
}
241246

247+
/**
248+
* Registers the handler for a custom extension request ({@code _}-prefixed method name, ACP v1
249+
* Extensibility) from the client, its params read as the given type, as
250+
* {@link AcpAgent.AsyncAgentBuilder#extRequestHandler(String, TypeRef, AcpAgent.ExtRequestHandler)}
251+
* does, for a handler that also needs the agent it serves, for example to call the client back
252+
* ({@link AcpAsyncAgent#sendExtRequest(String, Object, TypeRef)}) or send session updates. Name
253+
* the agent parameter {@code self}, not the name the built agent is assigned to.
254+
* @param <T> the params type
255+
* @param <R> the result type, any type the JSON mapper can write
256+
* @param method the method name, which must start with {@code _} (for example
257+
* {@code _example.com/workspace/buffers})
258+
* @param paramsType the type the params are read as; must not be null
259+
* @param handler the handler; it receives the params and the agent; must not be null
260+
* @return this builder
261+
* @throws IllegalArgumentException if the method name does not start with {@code _}
262+
*/
263+
public <T, R> B extRequestHandler(String method, TypeRef<T> paramsType, AgentAwareHandler<T, R> handler) {
264+
ExtensionMethods.requireExtension(method);
265+
Assert.notNull(paramsType, "Params type must not be null");
266+
Assert.notNull(handler, "Handler must not be null");
267+
return request(method, paramsType, handler::handle);
268+
}
269+
270+
/**
271+
* Registers the handler for a custom extension request ({@code _}-prefixed method name) from
272+
* the client, its params delivered as the raw JSON value (a {@code Map}, {@code List},
273+
* {@code String}, {@code Number} or {@code Boolean}), for a handler that also needs the agent
274+
* it serves.
275+
* @param <R> the result type, any type the JSON mapper can write
276+
* @param method the method name, which must start with {@code _}
277+
* @param handler the handler; it receives the params and the agent; must not be null
278+
* @return this builder
279+
* @throws IllegalArgumentException if the method name does not start with {@code _}
280+
* @see #extRequestHandler(String, TypeRef, AgentAwareHandler)
281+
*/
282+
public <R> B extRequestHandler(String method, AgentAwareHandler<Object, R> handler) {
283+
return extRequestHandler(method, AgentHandlers.RAW_PARAMS, handler);
284+
}
285+
286+
/**
287+
* Registers the handler for a custom extension notification ({@code _}-prefixed method name)
288+
* from the client, its params read as the given type, as
289+
* {@link AcpAgent.AsyncAgentBuilder#extNotificationHandler(String, TypeRef, AcpAgent.ExtNotificationHandler)}
290+
* does, for a handler that also needs the agent it serves, for example to answer with a
291+
* notification of its own ({@link AcpAsyncAgent#sendExtNotification(String, Object)}). Its
292+
* {@code Mono} completes when the notification is handled; a handler that fails is only
293+
* logged.
294+
* @param <T> the params type
295+
* @param method the method name, which must start with {@code _}
296+
* @param paramsType the type the params are read as; must not be null
297+
* @param handler the handler; it receives the params and the agent; must not be null
298+
* @return this builder
299+
* @throws IllegalArgumentException if the method name does not start with {@code _}
300+
*/
301+
public <T> B extNotificationHandler(String method, TypeRef<T> paramsType, AgentAwareHandler<T, Void> handler) {
302+
ExtensionMethods.requireExtension(method);
303+
Assert.notNull(paramsType, "Params type must not be null");
304+
Assert.notNull(handler, "Handler must not be null");
305+
return notification(method, paramsType, handler::handle);
306+
}
307+
308+
/**
309+
* Registers the handler for a custom extension notification ({@code _}-prefixed method name)
310+
* from the client, its params delivered as the raw JSON value, for a handler that also needs
311+
* the agent it serves.
312+
* @param method the method name, which must start with {@code _}
313+
* @param handler the handler; it receives the params and the agent; must not be null
314+
* @return this builder
315+
* @throws IllegalArgumentException if the method name does not start with {@code _}
316+
* @see #extNotificationHandler(String, TypeRef, AgentAwareHandler)
317+
*/
318+
public B extNotificationHandler(String method, AgentAwareHandler<Object, Void> handler) {
319+
return extNotificationHandler(method, AgentHandlers.RAW_PARAMS, handler);
320+
}
321+
242322
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ BooleanSupplier promptAnswered(String sessionId) {
161161

162162
private <T> AcpAgentSession.NotificationHandler sessionHandler(AgentHandlers.Notification<T> registration) {
163163
return params -> registration.handler()
164-
.apply(transport.unmarshalParams(params, registration.notificationType()));
164+
.handle(transport.unmarshalParams(params, registration.notificationType()), this);
165165
}
166166

167167
/**

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
package com.agentclientprotocol.sdk.agent;
66

7+
import com.agentclientprotocol.sdk.json.TypeRef;
78
import com.agentclientprotocol.sdk.spec.AcpSchema;
89
import reactor.core.publisher.Mono;
910

@@ -68,4 +69,19 @@ public Mono<Void> completeElicitation(AcpSchema.CompleteElicitationNotification
6869
return agent.completeElicitation(notification);
6970
}
7071

72+
@Override
73+
public <T> Mono<T> sendExtRequest(String method, Object params, TypeRef<T> resultType) {
74+
return agent.sendExtRequest(method, params, resultType);
75+
}
76+
77+
@Override
78+
public Mono<Object> sendExtRequest(String method, Object params) {
79+
return agent.sendExtRequest(method, params);
80+
}
81+
82+
@Override
83+
public Mono<Void> sendExtNotification(String method, Object params) {
84+
return agent.sendExtNotification(method, params);
85+
}
86+
7187
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
package com.agentclientprotocol.sdk.agent;
66

7+
import com.agentclientprotocol.sdk.json.TypeRef;
78
import com.agentclientprotocol.sdk.spec.AcpSchema;
89
import com.agentclientprotocol.sdk.spec.SyncCalls;
10+
import org.jspecify.annotations.Nullable;
911

1012
/**
1113
* The {@link SyncSessionClient} of a {@link DefaultSyncPromptContext}: each call blocks on the
@@ -69,4 +71,19 @@ public void completeElicitation(AcpSchema.CompleteElicitationNotification notifi
6971
SyncCalls.block(client.completeElicitation(notification));
7072
}
7173

74+
@Override
75+
public <T> @Nullable T sendExtRequest(String method, Object params, TypeRef<T> resultType) {
76+
return SyncCalls.block(client.sendExtRequest(method, params, resultType));
77+
}
78+
79+
@Override
80+
public @Nullable Object sendExtRequest(String method, Object params) {
81+
return SyncCalls.block(client.sendExtRequest(method, params));
82+
}
83+
84+
@Override
85+
public void sendExtNotification(String method, Object params) {
86+
SyncCalls.block(client.sendExtNotification(method, params));
87+
}
88+
7289
}

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
package com.agentclientprotocol.sdk.agent;
66

7+
import com.agentclientprotocol.sdk.json.TypeRef;
78
import com.agentclientprotocol.sdk.spec.AcpSchema;
89
import reactor.core.publisher.Mono;
910

@@ -15,7 +16,8 @@
1516
* {@link PromptContext#askPermission}, {@link PromptContext#execute} and the rest), which fill in
1617
* the session ID for you. Use this one for what the convenience calls do not cover: a line range,
1718
* a permission request for a tool call the agent announced itself, a terminal the handler manages
18-
* step by step, or an elicitation.
19+
* step by step, an elicitation, or a custom extension method of the client's ({@link #sendExtRequest},
20+
* {@link #sendExtNotification}).
1921
*
2022
* <pre>{@code
2123
* context.client()
@@ -140,4 +142,44 @@ public interface SessionClient {
140142
*/
141143
Mono<Void> completeElicitation(AcpSchema.CompleteElicitationNotification notification);
142144

145+
/**
146+
* Sends a custom extension request to the client and returns its result as the raw JSON value:
147+
* a {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}.
148+
* @param method the method name, which must start with {@code _}
149+
* @param params the params, any value the JSON mapper can write
150+
* @return a {@code Mono} emitting the result, or completing empty when the client answers
151+
* {@code "result": null}
152+
* @throws IllegalArgumentException if the method name does not start with {@code _}
153+
* @see #sendExtRequest(String, Object, TypeRef)
154+
*/
155+
Mono<Object> sendExtRequest(String method, Object params);
156+
157+
/**
158+
* Sends a custom extension request ({@code _}-prefixed method name, ACP v1 Extensibility) to
159+
* the client and reads its result as the given type, as
160+
* {@link AcpAsyncAgent#sendExtRequest(String, Object, TypeRef)} does. A client that does not
161+
* handle the method answers "Method not found" ({@code -32601}), which fails the {@code Mono}
162+
* with {@link com.agentclientprotocol.sdk.spec.AcpError}. The SDK checks no capability for
163+
* extension methods: advertise and check them in the {@code _meta} of the capability objects.
164+
* @param <T> the result type
165+
* @param method the method name, which must start with {@code _}
166+
* @param params the params, any value the JSON mapper can write
167+
* @param resultType the type the result is read as
168+
* @return a {@code Mono} emitting the result, or completing empty when the client answers
169+
* {@code "result": null}
170+
* @throws IllegalArgumentException if the method name does not start with {@code _}
171+
*/
172+
<T> Mono<T> sendExtRequest(String method, Object params, TypeRef<T> resultType);
173+
174+
/**
175+
* Sends a custom extension notification ({@code _}-prefixed method name) to the client, as
176+
* {@link AcpAsyncAgent#sendExtNotification(String, Object)} does. A client without a handler
177+
* for it ignores it.
178+
* @param method the method name, which must start with {@code _}
179+
* @param params the params, any value the JSON mapper can write
180+
* @return a {@code Mono} that completes when the notification has been handed to the transport
181+
* @throws IllegalArgumentException if the method name does not start with {@code _}
182+
*/
183+
Mono<Void> sendExtNotification(String method, Object params);
184+
143185
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
* {@link AcpAgent.SyncAgentBuilder} that also receives the agent it serves, the {@link AcpSyncAgent}
1212
* {@code build()} returned, so it can send session updates or call the client with blocking
1313
* calls. Every typed setter of the synchronous builder except {@code promptHandler} has an
14-
* overload that takes one; a two-argument lambda picks it.
14+
* overload that takes one, and so does {@code extRequestHandler} ({@code extNotificationHandler}
15+
* takes a {@link java.util.function.BiConsumer} of the params and the agent); a two-argument lambda
16+
* picks it.
1517
*
1618
* <pre>{@code
1719
* AcpSyncAgent agent = AcpAgent.sync(transport)

0 commit comments

Comments
 (0)