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:
@@ -16,6 +16,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
|
|||||||
- Interactive (and background) terminals
|
- Interactive (and background) terminals
|
||||||
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
|
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
|
||||||
- Client MCP servers
|
- Client MCP servers
|
||||||
|
- Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md)
|
||||||
|
|
||||||
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
||||||
|
|
||||||
|
|||||||
55
docs/goal-extension.md
Normal file
55
docs/goal-extension.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Goal extension
|
||||||
|
|
||||||
|
This document defines a provider-neutral experimental ACP extension implemented by `claude-agent-acp`. It is intentionally shaped like a possible future first-class ACP API: implementations publish `_meta.goal`, not provider-specific metadata such as `_meta.claudeCode.goal`.
|
||||||
|
|
||||||
|
## Capability negotiation
|
||||||
|
|
||||||
|
An agent advertises support in its `initialize` response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"goal": {
|
||||||
|
"version": 1,
|
||||||
|
"controlMethod": "_session/goal",
|
||||||
|
"actions": ["clear"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`actions` is the implementation-supported subset of `set`, `pause`, `resume`, and `clear`. Clients must not infer support for an action that is not advertised. The control request contains `sessionId` and `action`; a future version may add action-specific fields such as an objective for `set`.
|
||||||
|
|
||||||
|
## Session state
|
||||||
|
|
||||||
|
The current snapshot is published in `session_info_update._meta.goal`. Clearing a goal publishes `goal: null`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objective": "Ship the change",
|
||||||
|
"status": "active",
|
||||||
|
"iterations": 3,
|
||||||
|
"lastReason": "Tests still need work",
|
||||||
|
"createdAt": 1710000000123,
|
||||||
|
"controlMethod": "_session/goal"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common statuses are `active`, `paused`, `blocked`, `limited`, and `complete`. Optional fields allow implementations to report budgets, usage, iteration count, and the last continuation reason. Timestamps are Unix milliseconds.
|
||||||
|
|
||||||
|
## Lifecycle architecture
|
||||||
|
|
||||||
|
A goal belongs to the ACP session, not to an individual `session/prompt` request. Goal activity and prompt activity are independent:
|
||||||
|
|
||||||
|
- `status: active` means the persistent objective can drive more work; it does not mean an ACP prompt is currently executing.
|
||||||
|
- A prompt completes when its current backend turn reaches a quiescent boundary, even when the goal remains active.
|
||||||
|
- A later autonomous cycle may publish more session updates outside that completed prompt.
|
||||||
|
- While a turn is running, clients use steering or prompt queueing when advertised. While the session is quiescent, clients may send an ordinary `session/prompt`.
|
||||||
|
|
||||||
|
This separation prevents a persistent goal from monopolizing the session's prompt slot and lets clients model “working now” independently from “objective remains active.”
|
||||||
|
|
||||||
|
## Claude mapping
|
||||||
|
|
||||||
|
Claude's session-scoped `/goal` command installs a Stop hook. The runtime emits internal `active_goal` messages when that hook updates or clears its state; the adapter maps them into the neutral snapshot before the exhaustive SDK message switch. `condition`, `iterations`, `set_at`, and `last_reason` become `objective`, `iterations`, `createdAt`, and `lastReason`. Provider-only bookkeeping such as `tokens_at_start` is not exposed.
|
||||||
|
|
||||||
|
Only `clear` is advertised for out-of-band control. The adapter submits `/goal clear` through the existing session prompt queue, preserving the same ordering and lifecycle rules as other Claude commands. Setting a goal remains available through `/goal <condition>` and is not advertised as an extension action because it starts Claude's long-running Stop-hook workflow.
|
||||||
@@ -77,6 +77,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
query,
|
query,
|
||||||
SDKAssistantMessageError,
|
SDKAssistantMessageError,
|
||||||
|
SDKActiveGoalMessage,
|
||||||
SDKMessage,
|
SDKMessage,
|
||||||
SDKMessageOrigin,
|
SDKMessageOrigin,
|
||||||
SDKPartialAssistantMessage,
|
SDKPartialAssistantMessage,
|
||||||
@@ -84,6 +85,16 @@ import {
|
|||||||
SlashCommand,
|
SlashCommand,
|
||||||
ThinkingConfig,
|
ThinkingConfig,
|
||||||
} from "@anthropic-ai/claude-agent-sdk";
|
} 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 { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
||||||
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
|
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
@@ -1596,6 +1607,11 @@ export class ClaudeAcpAgent {
|
|||||||
steering: {
|
steering: {
|
||||||
supported: true,
|
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;
|
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
|
/** 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
|
* message into the turn that is currently running. If that turn already
|
||||||
* settled, the established default starts a new detached turn; Hosts may opt
|
* settled, the established default starts a new detached turn; Hosts may opt
|
||||||
@@ -2635,6 +2659,21 @@ export class ClaudeAcpAgent {
|
|||||||
continue;
|
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) {
|
switch (message.type) {
|
||||||
case "system":
|
case "system":
|
||||||
switch (message.subtype) {
|
switch (message.subtype) {
|
||||||
@@ -7879,6 +7918,11 @@ export function runAcp() {
|
|||||||
.onRequest<SteerRequest, SteerResponse>(STEER_METHOD, { parse: parseSteerRequest }, (ctx) =>
|
.onRequest<SteerRequest, SteerResponse>(STEER_METHOD, { parse: parseSteerRequest }, (ctx) =>
|
||||||
agent.steer(ctx.params),
|
agent.steer(ctx.params),
|
||||||
)
|
)
|
||||||
|
.onRequest<GoalRequest, GoalControlResponse>(
|
||||||
|
GOAL_CONTROL_METHOD,
|
||||||
|
{ parse: parseGoalRequest },
|
||||||
|
(ctx) => agent.goal(ctx.params),
|
||||||
|
)
|
||||||
.connect(stream);
|
.connect(stream);
|
||||||
|
|
||||||
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
|
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
|
||||||
|
|||||||
64
src/goal-extension.ts
Normal file
64
src/goal-extension.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { RequestError } from "@agentclientprotocol/sdk";
|
||||||
|
import type { SDKActiveGoalMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
|
export const GOAL_EXTENSION_VERSION = 1 as const;
|
||||||
|
export const GOAL_CONTROL_METHOD = "_session/goal";
|
||||||
|
|
||||||
|
export const GOAL_ACTIONS = ["clear"] as const;
|
||||||
|
export type GoalAction = (typeof GOAL_ACTIONS)[number];
|
||||||
|
|
||||||
|
export type GoalCapability = {
|
||||||
|
version: typeof GOAL_EXTENSION_VERSION;
|
||||||
|
controlMethod: typeof GOAL_CONTROL_METHOD;
|
||||||
|
actions: GoalAction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GoalStatus = "active" | "paused" | "blocked" | "limited" | "complete";
|
||||||
|
|
||||||
|
export type GoalSnapshot = {
|
||||||
|
objective: string;
|
||||||
|
status: GoalStatus;
|
||||||
|
iterations?: number;
|
||||||
|
lastReason?: string | null;
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
tokenBudget?: number | null;
|
||||||
|
tokensUsed?: number;
|
||||||
|
timeUsedSeconds?: number;
|
||||||
|
controlMethod: typeof GOAL_CONTROL_METHOD;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GoalRequest = {
|
||||||
|
sessionId: string;
|
||||||
|
action: GoalAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GoalControlResponse = Record<string, never>;
|
||||||
|
|
||||||
|
export function parseGoalRequest(params: unknown): GoalRequest {
|
||||||
|
if (!params || typeof params !== "object") {
|
||||||
|
throw RequestError.invalidParams(undefined, "goal params must be an object");
|
||||||
|
}
|
||||||
|
const { sessionId, action } = params as Record<string, unknown>;
|
||||||
|
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
||||||
|
throw RequestError.invalidParams(undefined, "goal params require a non-empty sessionId");
|
||||||
|
}
|
||||||
|
if (!GOAL_ACTIONS.includes(action as GoalAction)) {
|
||||||
|
throw RequestError.invalidParams(undefined, 'goal action must be "clear"');
|
||||||
|
}
|
||||||
|
return { sessionId, action: action as GoalAction };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toGoalSnapshot(message: SDKActiveGoalMessage): GoalSnapshot | null {
|
||||||
|
if (message.value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
objective: message.value.condition.trim(),
|
||||||
|
status: "active",
|
||||||
|
iterations: message.value.iterations,
|
||||||
|
lastReason: message.value.last_reason ?? null,
|
||||||
|
createdAt: message.value.set_at,
|
||||||
|
controlMethod: GOAL_CONTROL_METHOD,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
SDKAssistantMessage,
|
SDKAssistantMessage,
|
||||||
} from "@anthropic-ai/claude-agent-sdk";
|
} from "@anthropic-ai/claude-agent-sdk";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
import { GOAL_CONTROL_METHOD, parseGoalRequest, toGoalSnapshot } from "../goal-extension.js";
|
||||||
|
|
||||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||||
@@ -3299,6 +3300,93 @@ describe("stop reason propagation", () => {
|
|||||||
expect(errors.filter((e) => e.includes("Unexpected case"))).toEqual([]);
|
expect(errors.filter((e) => e.includes("Unexpected case"))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("publishes active_goal as provider-neutral session state without changing turn settlement", async () => {
|
||||||
|
const updates: any[] = [];
|
||||||
|
const mockClient = {
|
||||||
|
sessionUpdate: async (notification: any) => updates.push(notification),
|
||||||
|
} as unknown as AcpClient;
|
||||||
|
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||||
|
|
||||||
|
injectGeneratorSession(agent, (input) => {
|
||||||
|
async function* messageGenerator() {
|
||||||
|
const iter = input[Symbol.asyncIterator]();
|
||||||
|
const { value: userMessage } = await iter.next();
|
||||||
|
yield userEcho(userMessage);
|
||||||
|
yield {
|
||||||
|
type: "active_goal",
|
||||||
|
value: {
|
||||||
|
condition: " Finish the migration ",
|
||||||
|
iterations: 3,
|
||||||
|
set_at: 1710000000123,
|
||||||
|
tokens_at_start: 42,
|
||||||
|
last_reason: "Tests still need work",
|
||||||
|
},
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: "test-session",
|
||||||
|
};
|
||||||
|
yield createResultMessage({ subtype: "success", stop_reason: "end_turn", is_error: false });
|
||||||
|
yield {
|
||||||
|
type: "active_goal",
|
||||||
|
value: null,
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: "test-session",
|
||||||
|
};
|
||||||
|
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||||
|
}
|
||||||
|
return messageGenerator();
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }),
|
||||||
|
).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" }));
|
||||||
|
expect(updates).toContainEqual({
|
||||||
|
sessionId: "test-session",
|
||||||
|
update: {
|
||||||
|
sessionUpdate: "session_info_update",
|
||||||
|
_meta: {
|
||||||
|
goal: {
|
||||||
|
objective: "Finish the migration",
|
||||||
|
status: "active",
|
||||||
|
iterations: 3,
|
||||||
|
lastReason: "Tests still need work",
|
||||||
|
createdAt: 1710000000123,
|
||||||
|
controlMethod: GOAL_CONTROL_METHOD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updates).toContainEqual({
|
||||||
|
sessionId: "test-session",
|
||||||
|
update: { sessionUpdate: "session_info_update", _meta: { goal: null } },
|
||||||
|
});
|
||||||
|
expect(updates.some((notification) => notification.update?._meta?.claudeCode?.goal)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the SDK goal shape without exposing provider fields", () => {
|
||||||
|
expect(
|
||||||
|
toGoalSnapshot({
|
||||||
|
type: "active_goal",
|
||||||
|
value: {
|
||||||
|
condition: "Goal",
|
||||||
|
iterations: 1,
|
||||||
|
set_at: 1710000000000,
|
||||||
|
tokens_at_start: 99,
|
||||||
|
},
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: "test-session",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
objective: "Goal",
|
||||||
|
status: "active",
|
||||||
|
iterations: 1,
|
||||||
|
lastReason: null,
|
||||||
|
createdAt: 1710000000000,
|
||||||
|
controlMethod: GOAL_CONTROL_METHOD,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("settles a no-echo command result (e.g. /compact) by promoting the head turn", async () => {
|
it("settles a no-echo command result (e.g. /compact) by promoting the head turn", async () => {
|
||||||
// Regression: /compact never echoes a user message carrying the prompt's
|
// Regression: /compact never echoes a user message carrying the prompt's
|
||||||
// uuid (its only user messages are the generated summary and a
|
// uuid (its only user messages are the generated summary and a
|
||||||
@@ -9905,6 +9993,32 @@ describe("turn steering (_session/steering)", () => {
|
|||||||
expect((response._meta as any)?.steering).toEqual({
|
expect((response._meta as any)?.steering).toEqual({
|
||||||
supported: true,
|
supported: true,
|
||||||
});
|
});
|
||||||
|
expect((response._meta as any)?.goal).toEqual({
|
||||||
|
version: 1,
|
||||||
|
controlMethod: GOAL_CONTROL_METHOD,
|
||||||
|
actions: ["clear"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits clear through the session prompt queue", async () => {
|
||||||
|
const agent = createMockAgent();
|
||||||
|
const prompt = vi.spyOn(agent, "prompt").mockResolvedValue({ stopReason: "end_turn" });
|
||||||
|
|
||||||
|
await expect(agent.goal({ sessionId: "test-session", action: "clear" })).resolves.toEqual({});
|
||||||
|
expect(prompt).toHaveBeenCalledWith({
|
||||||
|
sessionId: "test-session",
|
||||||
|
prompt: [{ type: "text", text: "/goal clear" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates goal control requests", () => {
|
||||||
|
expect(parseGoalRequest({ sessionId: "test-session", action: "clear" })).toEqual({
|
||||||
|
sessionId: "test-session",
|
||||||
|
action: "clear",
|
||||||
|
});
|
||||||
|
expect(() => parseGoalRequest({ sessionId: "test-session", action: "pause" })).toThrow(
|
||||||
|
'goal action must be "clear"',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves startedNewTurn by default when no turn is in flight", async () => {
|
it("preserves startedNewTurn by default when no turn is in flight", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user