Skip to content

Commit 3d46165

Browse files
author
Mark Pollack
committed
Advertise what an annotated agent's annotations declare in its initialize response
Without an @initialize method an annotated agent answered InitializeResponse.ok(), so every handler beyond the baseline went unadvertised and @AcpAgent's name and version were never sent. The response is now derived from the annotation model: handler annotations map to capabilities, @AcpAgent gives agentInfo, auth methods and MCP transports, and @prompt the prompt content accepted. An @initialize response is laid over the derived one.
1 parent bc82d66 commit 3d46165

20 files changed

Lines changed: 1309 additions & 96 deletions

File tree

‎CHANGELOG.md‎

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

534534
### Fixed
535535

536+
- **An annotated agent advertises what its annotations declare.** Without an `@Initialize` method,
537+
`AcpAgentSupport` answered `initialize` with `InitializeResponse.ok()`: `loadSession: false`, no
538+
session, auth or provider capabilities, no `authMethods` and no `agentInfo`. An agent with
539+
`@LoadSession`, `@ListSessions`, `@ResumeSession`, `@CloseSession`, `@DeleteSession`, `@Logout`
540+
or `@Authenticate` told clients it supported none of them, and clients that honour capabilities
541+
never called those methods; `@AcpAgent(name, version)` was never sent. The response is now
542+
derived from the annotation model:
543+
- each handler annotation advertises the capability its method needs: `@LoadSession` →
544+
`loadSession`; `@ListSessions`, `@ResumeSession`, `@CloseSession`, `@DeleteSession` →
545+
`sessionCapabilities.list`/`resume`/`close`/`delete`; `@Logout` → `auth.logout`; the unstable
546+
`@ForkSession` → `sessionCapabilities.fork` and `@ListProviders`/`@SetProvider`/`@DisableProvider`
547+
→ `providers`. The others need no capability (baseline methods, per-session modes and config
548+
options, extensions), and `HandlerAnnotationCoverageTest` fails if a new handler annotation has
549+
no declared mapping;
550+
- `@AcpAgent(name, version, title)` → `agentInfo`; a blank name sends the class's simple name, a
551+
blank version the jar manifest's `Implementation-Version`, else `"unknown"`;
552+
- new `@AcpAgent(authMethods = @AuthMethod(...))` → `authMethods`, agent or terminal methods.
553+
Terminal methods are advertised only to a client that announced
554+
`clientCapabilities.auth.terminal`. Declaring an agent method without an `@Authenticate`
555+
handler fails the build, as do duplicate ids and an `env` entry not written `NAME=value`;
556+
- new `@Prompt(image, audio, embeddedContext)` → `promptCapabilities` and
557+
`@AcpAgent(mcpHttp, mcpSse)` → `mcpCapabilities`, default false;
558+
- `protocolVersion` is the client's when the SDK speaks it, otherwise the latest it speaks.
559+
560+
An `@Initialize` method is now optional. When present, its response is laid over the derived
561+
one: a capability is advertised when either side advertises it, so existing code returning
562+
`InitializeResponse.ok()` keeps every derived capability; returned `authMethods` follow the
563+
derived ones, replacing any with the same id; the returned `protocolVersion`, and the returned
564+
`agentInfo` and `_meta` when not null, win.
565+
566+
**Migration:** an `@Initialize` that only returned `InitializeResponse.ok()` can be deleted. One
567+
that built capabilities by hand can drop what the annotations now derive. A capability a handler
568+
implies can no longer be withdrawn from `@Initialize`; remove the handler instead.
536569
- **`session/close` cancels the session's ongoing work before it closes.** ACP v1 says the agent
537570
"**must** cancel any ongoing work related to the session (treat it as if `session/cancel` was
538571
called) and then free up any resources associated with the session" (schema

‎README.md‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,14 +167,9 @@ import com.agentclientprotocol.sdk.agent.SyncPromptContext;
167167
import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport;
168168
import com.agentclientprotocol.sdk.spec.AcpSchema.*;
169169

170-
@AcpAgent
170+
@AcpAgent(name = "hello-agent", version = "1.0")
171171
class HelloAgent {
172172

173-
@Initialize
174-
InitializeResponse init() {
175-
return InitializeResponse.ok();
176-
}
177-
178173
@NewSession
179174
NewSessionResponse newSession() {
180175
return new NewSessionResponse(UUID.randomUUID().toString(), null, null);
@@ -193,6 +188,10 @@ AcpAgentSupport.create(new HelloAgent())
193188
.run();
194189
```
195190

191+
The agent answers `initialize` with what its annotations declare: a capability for each handler
192+
(`@LoadSession` advertises `loadSession`, and so on), `agentInfo` from `@AcpAgent`, and declared
193+
auth methods, prompt content and MCP transports. No `@Initialize` method is needed.
194+
196195
### 3. Hello World Agent (Sync)
197196

198197
The builder API with blocking handlers and plain return values ([tutorial](https://github.com/markpollack/acp-java-tutorial/tree/main/module-13-agent-handlers)):

‎acp-agent-support/README.md‎

Lines changed: 93 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,9 @@ The `acp-agent-support` module provides an annotation-based programming model fo
55
## Quick Start
66

77
```java
8-
@AcpAgent
8+
@AcpAgent(name = "my-agent", version = "1.0.0")
99
class MyAgent {
1010

11-
@Initialize
12-
InitializeResponse init() {
13-
return InitializeResponse.ok();
14-
}
15-
1611
@NewSession
1712
NewSessionResponse newSession(NewSessionRequest req) {
1813
return new NewSessionResponse("session-" + UUID.randomUUID(), null, null);
@@ -32,6 +27,9 @@ AcpAgentSupport.create(new MyAgent())
3227
.run();
3328
```
3429

30+
No `@Initialize` method is needed: the agent answers `initialize` with what its annotations declare
31+
(see [Advertising Capabilities](#advertising-capabilities)).
32+
3533
## Installation
3634

3735
Add the dependency to your `pom.xml`:
@@ -52,31 +50,32 @@ This module transitively includes `acp-annotations` and `acp-core`.
5250

5351
| Annotation | Description |
5452
|------------|-------------|
55-
| `@AcpAgent` | Marks a class as an ACP agent. Required on all agent classes. Its optional `name` and `version` attributes are descriptive only: the runtime does not read them, so report the agent's identity in the `InitializeResponse` (`agentInfo`). |
53+
| `@AcpAgent` | Marks a class as an ACP agent. Required on all agent classes. Its attributes are advertised in the `initialize` response: `name`, `version` and `title` as `agentInfo` (the name defaults to the class's simple name, the version to the jar manifest's `Implementation-Version`, else `"unknown"`), `authMethods` (`@AuthMethod`s) as `authMethods`, and `mcpHttp`/`mcpSse` as `mcpCapabilities`. |
54+
| `@AuthMethod` | One authentication method inside `@AcpAgent(authMethods = ...)`: `id`, `name`, `description`, and `type` `AGENT` (served by `@Authenticate`) or `TERMINAL` (the client reruns the agent program with `args` and `env`). |
5655

5756
### Handler Methods
5857

59-
| Annotation | JSON-RPC Method | Description |
60-
|------------|-----------------|-------------|
61-
| `@Initialize` | `initialize` | Handles protocol initialization and capability negotiation. Without one, the agent answers `InitializeResponse.ok()`. |
62-
| `@Authenticate` | `authenticate` | Authenticates with one of the methods the agent advertised in `authMethods`. |
63-
| `@Logout` | `logout` | Clears stored credentials. |
64-
| `@NewSession` | `session/new` | Creates a new agent session. Without one, the agent answers with a random session ID. |
65-
| `@LoadSession` | `session/load` | Loads an existing session by ID, replaying its history. |
66-
| `@ResumeSession` | `session/resume` | Reconnects to an existing session without replaying history. |
67-
| `@ListSessions` | `session/list` | Lists sessions, optionally filtered by working directory. |
68-
| `@CloseSession` | `session/close` | Closes an active session. |
69-
| `@DeleteSession` | `session/delete` | Permanently deletes a stored session. |
70-
| `@ForkSession` | `session/fork` | Creates a session branched from an existing one (unstable, `@UnstableAcpApi`). |
71-
| `@Prompt` | `session/prompt` | Handles user prompts within a session. |
72-
| `@SetSessionMode` | `session/set_mode` | Changes the operational mode of a session. |
73-
| `@SetSessionConfigOption` | `session/set_config_option` | Changes a session configuration option. |
74-
| `@ListProviders` | `providers/list` | Lists the providers the agent can route to (unstable). |
75-
| `@SetProvider` | `providers/set` | Configures a provider (unstable). |
76-
| `@DisableProvider` | `providers/disable` | Disables a provider (unstable). |
77-
| `@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. |
78-
| `@ExtRequest("_name")` | any `_`-prefixed request | Serves a custom extension request; the name must start with `_`. |
79-
| `@ExtNotification("_name")` | any `_`-prefixed notification | Handles a custom extension notification; the name must start with `_`. |
58+
| Annotation | JSON-RPC Method | Advertises | Description |
59+
|------------|-----------------|------------|-------------|
60+
| `@Initialize` | `initialize` | (the exchange itself) | Optional. Without one, the agent answers with the response derived from its annotations; with one, the method's response is laid over the derived one (see [Advertising Capabilities](#advertising-capabilities)). |
61+
| `@Authenticate` | `authenticate` | `authMethods`, from `@AcpAgent(authMethods = ...)` | Authenticates with one of the agent methods the agent advertised. |
62+
| `@Logout` | `logout` | `auth.logout` | Clears stored credentials. |
63+
| `@NewSession` | `session/new` | nothing (baseline) | Creates a new agent session. Without one, the agent answers with a random session ID. |
64+
| `@LoadSession` | `session/load` | `loadSession` | Loads an existing session by ID, replaying its history. |
65+
| `@ResumeSession` | `session/resume` | `sessionCapabilities.resume` | Reconnects to an existing session without replaying history. |
66+
| `@ListSessions` | `session/list` | `sessionCapabilities.list` | Lists sessions, optionally filtered by working directory. |
67+
| `@CloseSession` | `session/close` | `sessionCapabilities.close` | Closes an active session. |
68+
| `@DeleteSession` | `session/delete` | `sessionCapabilities.delete` | Permanently deletes a stored session. |
69+
| `@ForkSession` | `session/fork` | `sessionCapabilities.fork` | Creates a session branched from an existing one (unstable, `@UnstableAcpApi`). |
70+
| `@Prompt` | `session/prompt` | `promptCapabilities`, from its `image`, `audio` and `embeddedContext` attributes | Handles user prompts within a session. |
71+
| `@SetSessionMode` | `session/set_mode` | nothing (modes are offered per session) | Changes the operational mode of a session. |
72+
| `@SetSessionConfigOption` | `session/set_config_option` | nothing (options are offered per session) | Changes a session configuration option. |
73+
| `@ListProviders` | `providers/list` | `providers` | Lists the providers the agent can route to (unstable). |
74+
| `@SetProvider` | `providers/set` | `providers` | Configures a provider (unstable). |
75+
| `@DisableProvider` | `providers/disable` | `providers` | Disables a provider (unstable). |
76+
| `@Cancel` | `session/cancel` | nothing (baseline) | 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. |
77+
| `@ExtRequest("_name")` | any `_`-prefixed request | nothing (agreed outside the protocol) | Serves a custom extension request; the name must start with `_`. |
78+
| `@ExtNotification("_name")` | any `_`-prefixed notification | nothing | Handles a custom extension notification; the name must start with `_`. |
8079

8180
A method without a handler for it is answered with `-32601` (Method not found); a notification
8281
without one is ignored.
@@ -87,6 +86,68 @@ without one is ignored.
8786
|------------|-------------|
8887
| `@SessionId` | Injects the current session ID as a `String`, in handlers of session-scoped methods. |
8988

89+
## Advertising Capabilities
90+
91+
A client learns what an agent supports from the agent's `initialize` response, and only uses what
92+
is advertised there: a client that is not told `loadSession` never calls `session/load`. An annotated
93+
agent's response is derived from its class, so every handler it has is advertised:
94+
95+
```java
96+
@AcpAgent(name = "notes-agent", version = "1.2.0", mcpHttp = true,
97+
authMethods = {
98+
@AuthMethod(id = "api-key", name = "API key", description = "Uses NOTES_API_KEY"),
99+
@AuthMethod(id = "login", name = "Log in", type = AuthMethod.Type.TERMINAL, args = "--login") })
100+
class NotesAgent {
101+
102+
@Authenticate AuthenticateResponse authenticate(AuthenticateRequest req) { ... }
103+
@Logout LogoutResponse logout() { ... }
104+
@LoadSession LoadSessionResponse load(LoadSessionRequest req) { ... }
105+
@ListSessions ListSessionsResponse list(ListSessionsRequest req) { ... }
106+
107+
@Prompt(image = true, embeddedContext = true)
108+
PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { ... }
109+
}
110+
```
111+
112+
answers `initialize` with
113+
114+
```json
115+
{
116+
"protocolVersion": 1,
117+
"agentCapabilities": {
118+
"loadSession": true,
119+
"sessionCapabilities": { "list": {} },
120+
"mcpCapabilities": { "http": true, "sse": false },
121+
"promptCapabilities": { "audio": false, "embeddedContext": true, "image": true },
122+
"auth": { "logout": {} }
123+
},
124+
"authMethods": [
125+
{ "id": "api-key", "name": "API key", "description": "Uses NOTES_API_KEY" },
126+
{ "id": "login", "name": "Log in", "args": ["--login"], "type": "terminal" }
127+
],
128+
"agentInfo": { "name": "notes-agent", "version": "1.2.0" }
129+
}
130+
```
131+
132+
- **Capabilities** come from the handler annotations, as the *Advertises* column of the
133+
[handler table](#handler-methods) lists. A test fails the build if a handler annotation has no
134+
declared mapping.
135+
- **Prompt content and MCP transports** the agent accepts are declared, default false:
136+
`@Prompt(image, audio, embeddedContext)` and `@AcpAgent(mcpHttp, mcpSse)`.
137+
- **Auth methods** are declared with `@AcpAgent(authMethods = @AuthMethod(...))`. An `AGENT` method
138+
needs an `@Authenticate` handler (the builder fails otherwise); a `TERMINAL` method is advertised
139+
only to a client that announced `clientCapabilities.auth.terminal`, as ACP requires.
140+
- **`agentInfo`** is `@AcpAgent`'s `name`, `version` and `title`.
141+
- **`protocolVersion`** is the client's when the SDK speaks it, otherwise the latest the SDK speaks.
142+
143+
**With an `@Initialize` method**, the derived response is the base and the method's response is laid
144+
over it: a capability is advertised when either side advertises it (so `return InitializeResponse.ok()`
145+
keeps every derived capability, and a handler's capability cannot be withdrawn: remove the handler);
146+
the returned `authMethods` follow the derived ones, replacing any with the same id; the returned
147+
`protocolVersion`, and the returned `agentInfo` and `_meta` when not null, win. Use it to read the
148+
client's request, or to advertise what annotations cannot express, such as
149+
`sessionCapabilities.additionalDirectories`.
150+
90151
## Handler Method Signatures
91152

92153
Handler methods support flexible signatures. The runtime automatically resolves parameters based on their types:
@@ -394,12 +455,6 @@ class CodeAssistant {
394455

395456
private final Map<String, List<String>> sessionHistory = new ConcurrentHashMap<>();
396457

397-
@Initialize
398-
InitializeResponse init(InitializeRequest req) {
399-
// Customize response based on client capabilities
400-
return InitializeResponse.ok();
401-
}
402-
403458
@NewSession
404459
NewSessionResponse newSession(NewSessionRequest req) {
405460
String sessionId = UUID.randomUUID().toString();
@@ -483,9 +538,8 @@ AcpAgent.sync(transport)
483538
### Annotation API (After)
484539

485540
```java
486-
@AcpAgent
541+
@AcpAgent(name = "my-agent", version = "1.0")
487542
class MyAgent {
488-
@Initialize InitializeResponse init() { return InitializeResponse.ok(); }
489543
@NewSession NewSessionResponse newSession() { return new NewSessionResponse("session-1", null, null); }
490544
@Prompt PromptResponse prompt(SyncPromptContext ctx) {
491545
ctx.sendMessage("Hello!");
@@ -496,7 +550,9 @@ class MyAgent {
496550
AcpAgentSupport.create(new MyAgent()).transport(transport).run();
497551
```
498552

499-
Both approaches produce identical runtime behavior and can coexist in the same application.
553+
Both approaches produce the same runtime behavior and can coexist in the same application. The
554+
annotated agent needs no initialize handler: it advertises what its annotations declare, where a
555+
builder agent's initialize handler must build the response itself.
500556

501557
## Architecture
502558

0 commit comments

Comments
 (0)