mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: expose provider-neutral ACP goal extension (#964)
## Summary
- advertise a provider-neutral goal extension in
`InitializeResponse._meta.goal`
- map Claude SDK `active_goal` messages to
`session_info_update._meta.goal`
- expose the shared `_session/goal` control method with the truthfully
supported `clear` action
- document the extension as a candidate shape for a future first-class
ACP goal API
## Architecture
A goal is persistent **session state**, not the lifecycle of one
`session/prompt` request. `goal.status === "active"` says that the
objective may drive more work; it does not say that a prompt is
executing now.
This keeps two independent axes:
1. **Goal state** is published asynchronously as session metadata and
may survive multiple prompt turns.
2. **Prompt activity** follows the backend's actual running/quiescent
boundaries. While a turn runs, clients use advertised steering or prompt
queueing. Once the backend is quiescent, an ordinary `session/prompt` is
valid even if the goal remains active.
The metadata is deliberately provider-neutral:
```json
{
"_meta": {
"goal": {
"version": 1,
"controlMethod": "_session/goal",
"actions": ["clear"]
}
}
}
```
State is emitted as `session_info_update._meta.goal`; `null` clears it.
The common snapshot vocabulary supports `objective`, common statuses,
timestamps in Unix milliseconds, budgets/usage, iteration count, and a
continuation reason. Each implementation emits only fields it actually
knows. This shape is intentionally close to a future typed ACP
capability/update so promotion out of `_meta` would not require a
provider-specific migration.
Control actions are capability-negotiated. The shared vocabulary
reserves `set`, `pause`, `resume`, and `clear`, but an agent advertises
only the subset it can implement with correct semantics.
## Claude mapping
The Claude runtime already emits internal `active_goal` messages,
although that message is not currently included in the public
`SDKMessage` union. The adapter handles it before the exhaustive switch:
- `condition` → `objective`
- `iterations` → `iterations`
- `set_at` → `createdAt`
- `last_reason` → `lastReason`
- `value: null` → `goal: null`
Provider bookkeeping such as `tokens_at_start` is intentionally not
exposed. Goal updates do not settle, extend, or otherwise mutate ACP
prompt lifecycle. Claude's existing Stop hook remains authoritative for
whether the backend itself continues working.
Only `clear` is advertised for out-of-band control. It submits `/goal
clear` through the existing session prompt queue, preserving the
adapter's established ordering and turn ownership. Setting a goal
remains available through `/goal <condition>` but is not advertised as a
control action because it starts the long-running Stop-hook workflow.
The companion Codex implementation is agentclientprotocol/codex-acp#371.
## Verification
- `npm run test:run` — 692 passed, 20 skipped
- `npm run build`
- `npm run check`
This commit is contained in:
@@ -77,6 +77,7 @@ import {
|
||||
Query,
|
||||
query,
|
||||
SDKAssistantMessageError,
|
||||
SDKActiveGoalMessage,
|
||||
SDKMessage,
|
||||
SDKMessageOrigin,
|
||||
SDKPartialAssistantMessage,
|
||||
@@ -84,6 +85,16 @@ import {
|
||||
SlashCommand,
|
||||
ThinkingConfig,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
GOAL_ACTIONS,
|
||||
GOAL_CONTROL_METHOD,
|
||||
GOAL_EXTENSION_VERSION,
|
||||
GoalCapability,
|
||||
GoalRequest,
|
||||
GoalControlResponse,
|
||||
parseGoalRequest,
|
||||
toGoalSnapshot,
|
||||
} from "./goal-extension.js";
|
||||
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { execFile } from "node:child_process";
|
||||
@@ -1596,6 +1607,11 @@ export class ClaudeAcpAgent {
|
||||
steering: {
|
||||
supported: true,
|
||||
},
|
||||
goal: {
|
||||
version: GOAL_EXTENSION_VERSION,
|
||||
controlMethod: GOAL_CONTROL_METHOD,
|
||||
actions: [...GOAL_ACTIONS],
|
||||
} satisfies GoalCapability,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1894,6 +1910,14 @@ export class ClaudeAcpAgent {
|
||||
return response;
|
||||
}
|
||||
|
||||
async goal(params: GoalRequest): Promise<GoalControlResponse> {
|
||||
await this.prompt({
|
||||
sessionId: params.sessionId,
|
||||
prompt: [{ type: "text", text: "/goal clear" }],
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Steer the session per the ACP steering wire protocol: inject a follow-up
|
||||
* message into the turn that is currently running. If that turn already
|
||||
* settled, the established default starts a new detached turn; Hosts may opt
|
||||
@@ -2635,6 +2659,21 @@ export class ClaudeAcpAgent {
|
||||
continue;
|
||||
}
|
||||
|
||||
// `active_goal` is emitted by the Claude runtime but is not currently
|
||||
// included in the public SDKMessage union. Handle it before the
|
||||
// exhaustive switch and publish only the provider-neutral ACP shape.
|
||||
if ((message as { type: string }).type === "active_goal") {
|
||||
const activeGoal = message as unknown as SDKActiveGoalMessage;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
_meta: { goal: toGoalSnapshot(activeGoal) },
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
switch (message.subtype) {
|
||||
@@ -7879,6 +7918,11 @@ export function runAcp() {
|
||||
.onRequest<SteerRequest, SteerResponse>(STEER_METHOD, { parse: parseSteerRequest }, (ctx) =>
|
||||
agent.steer(ctx.params),
|
||||
)
|
||||
.onRequest<GoalRequest, GoalControlResponse>(
|
||||
GOAL_CONTROL_METHOD,
|
||||
{ parse: parseGoalRequest },
|
||||
(ctx) => agent.goal(ctx.params),
|
||||
)
|
||||
.connect(stream);
|
||||
|
||||
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
|
||||
|
||||
Reference in New Issue
Block a user