Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions MIGRATION_STABLE_UNSTABLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,19 @@ of APIs that require `/unstable` include:
- Turn `usage` on v1 prompt responses and usage on v2 idle state updates.
- `session/options`, and `configOptions` on `session/new`, `session/load`
(v1), and `session/resume`.
- Subagent capabilities and session updates, and `session` tool call content.
- V1 subagent capabilities and session updates, and `session` tool call
content. In v2 they are stable: `subagent_update`, `session_message`,
`session_message_chunk`, the `unknown` state, `subagent` background work, and
`session` tool call content are available from
`@agentclientprotocol/sdk/experimental/v2`.
- Prompt suggestions: the `prompt_suggestions_update` session update and the
`session.promptSuggestions` client capability.
- V2 skills: `skill` command entries, `skill` content blocks, and the
`session.commands.skill` and `session.prompt.skill` capabilities.
- Additional plan representations and `plan_removed` updates. V1's existing
stable `plan` update and v2's stable item-based `plan_update` remain available
from their stable-feature entrypoints. V1's ID-based `plan_update`, and v2's
file and markdown plan representations, require the corresponding unstable
entrypoint.
markdown plan representation, require the corresponding unstable entrypoint.

For each protocol version, the stable entrypoint's generated types and validators
come from upstream's `schema.json` and `meta.json`. Its unstable entrypoint uses
Expand Down
219 changes: 175 additions & 44 deletions schema/v1/schema.unstable.json

Large diffs are not rendered by default.

476 changes: 474 additions & 2 deletions schema/v2/schema.json

Large diffs are not rendered by default.

539 changes: 468 additions & 71 deletions schema/v2/schema.unstable.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions scripts/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import * as prettier from "prettier";
import ts from "typescript";
import { generateBindings } from "./generate-bindings.js";

const CURRENT_V1_SCHEMA_RELEASE = "schema-v1.31.0";
const CURRENT_V2_SCHEMA_RELEASE = "schema-v2.0.0-alpha.15";
const CURRENT_V1_SCHEMA_RELEASE = "schema-v1.32.0";
const CURRENT_V2_SCHEMA_RELEASE = "schema-v2.0.0-alpha.16";
const CHECK_GENERATED = process.argv.includes("--check");

// ── Extensible-union pipeline ────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion src/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This directory contains examples using the [ACP](https://agentclientprotocol.com

- [`agent.ts`](./agent.ts) - A minimal agent implementation that simulates LLM interaction
- [`client.ts`](./client.ts) - A minimal client implementation that spawns the [`agent.ts`](./agent.ts) as a subprocess
- [`dual-version-agent.ts`](./dual-version-agent.ts) - An agent that serves ACP v1 and the experimental draft ACP v2 through `agentProtocolRouter`, holding prompts that arrive during a turn, cancelling a pending v2 permission request with `$/cancel_request` on `session/cancel` or `session/close`, and making `session/close` idempotent
- [`dual-version-agent.ts`](./dual-version-agent.ts) - An agent that serves ACP v1 and the experimental draft ACP v2 through `agentProtocolRouter`, holding prompts that arrive during a turn, ending a v2 history replay with `replay_complete`, cancelling a pending v2 permission request with `$/cancel_request` on `session/cancel` or `session/close`, and making `session/close` idempotent
- [`http-server.ts`](./http-server.ts) - A minimal ACP Streamable HTTP server with WebSocket upgrade support
- [`http-client.ts`](./http-client.ts) - A minimal client using `createHttpStream`
- [`ws-client.ts`](./ws-client.ts) - A minimal client using `createWebSocketStream`
Expand Down
120 changes: 83 additions & 37 deletions src/examples/dual-version-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ type V2Session = {
state: v2.StateUpdate;
turn?: V2Turn;
held: HeldPrompt[];
/** Settles when the history replay in progress has sent `replay_complete`. */
replay?: Promise<void>;
};

// No `session.mcp` transports are advertised, so `assertSupportedMcpServers`
Expand Down Expand Up @@ -122,21 +124,35 @@ const v2Agent = v2
}

if (params.replayFrom?.type === "start") {
for (const update of session.history) {
await client.notify(v2.methods.client.session.update, {
sessionId: params.sessionId,
update,
});
// The history up to here is replayed. A turn that is still running sends
// its later updates through `sendV2Update`, which holds them until
// `replay_complete` is sent.
const replay = replayV2History(params.sessionId, client, [
...session.history,
]);
const settled = replay.then(
() => {},
() => {},
);
session.replay = settled;
try {
await replay;
} finally {
if (session.replay === settled) {
session.replay = undefined;
}
}
} else if (params.replayFrom) {
// An unsupported cursor replays nothing and gets no `replay_complete`.
throw v2.RequestError.invalidParams(
`Unsupported replay cursor ${params.replayFrom.type}`,
);
}

session.active = true;
// Synchronize current state after replay, even when no replay was requested.
// History contains messages only; historical states are not live activity.
// Synchronize current state after `replay_complete`, or right away when no
// replay was requested. History contains messages only; historical states
// are not live activity.
await client.notify(v2.methods.client.session.update, {
sessionId: params.sessionId,
update: { sessionUpdate: "session_info_update" },
Expand Down Expand Up @@ -284,6 +300,42 @@ function insertV2Prompt(
return { messageId: userMessageId };
}

/**
* Replays `history`, then marks the end of the replay with `replay_complete`.
* The marker is sent exactly once per replay, also when the history is empty.
*/
async function replayV2History(
sessionId: string,
client: v2.AgentContext,
history: v2.SessionUpdate[],
): Promise<void> {
for (const update of history) {
await client.notify(v2.methods.client.session.update, {
sessionId,
update,
});
}
await client.notify(v2.methods.client.session.update, {
sessionId,
update: { sessionUpdate: "replay_complete" },
});
}

/**
* Sends a live update. Between a `session/resume` request with `replayFrom`
* and its `replay_complete`, the Client reads every update for the session
* as replayed history, so live updates wait for the replay to finish.
*/
async function sendV2Update(
sessionId: string,
client: v2.AgentContext,
session: V2Session,
update: v2.SessionUpdate,
): Promise<void> {
await session.replay;
await client.notify(v2.methods.client.session.update, { sessionId, update });
}

function requireV2Session(sessionId: string): V2Session {
const session = v2Sessions.get(sessionId);
if (!session) {
Expand Down Expand Up @@ -354,10 +406,13 @@ async function runV2ToolCall(
let approved = false;
try {
session.state = { state: "requires_action" };
await client.notify(v2.methods.client.session.update, {
sessionId,
update: { sessionUpdate: "state_update", ...session.state },
await sendV2Update(sessionId, client, session, {
sessionUpdate: "state_update",
...session.state,
});
// A permission request is not history either, so it also waits for a
// replay in progress.
await session.replay;
// `stopWaitingOnCancel` makes an abort send `$/cancel_request` and reject
// with `-32800` right away. The Client still answers, normally with the
// `cancelled` outcome, and the SDK drops that late answer.
Expand Down Expand Up @@ -387,20 +442,17 @@ async function runV2ToolCall(
response.outcome.optionId === "allow";

session.state = { state: "running" };
await client.notify(v2.methods.client.session.update, {
sessionId,
update: { sessionUpdate: "state_update", ...session.state },
await sendV2Update(sessionId, client, session, {
sessionUpdate: "state_update",
...session.state,
});
} finally {
// A rejected, cancelled, or abandoned permission request leaves the tool
// call unapproved, so it ends as `cancelled` rather than `pending`.
await client.notify(v2.methods.client.session.update, {
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: approved ? "completed" : "cancelled",
},
await sendV2Update(sessionId, client, session, {
sessionUpdate: "tool_call_update",
toolCallId,
status: approved ? "completed" : "cancelled",
});
}
}
Expand All @@ -413,16 +465,13 @@ async function runV2Turn(
userMessage: v2.SessionUpdate,
): Promise<void> {
try {
await client.notify(v2.methods.client.session.update, {
sessionId: params.sessionId,
update: userMessage,
});
await sendV2Update(params.sessionId, client, session, userMessage);

signal.throwIfAborted();
session.state = { state: "running" };
await client.notify(v2.methods.client.session.update, {
sessionId: params.sessionId,
update: { sessionUpdate: "state_update", ...session.state },
await sendV2Update(params.sessionId, client, session, {
sessionUpdate: "state_update",
...session.state,
});
await waitForWork(signal);

Expand All @@ -436,16 +485,13 @@ async function runV2Turn(
content: [{ type: "text", text: "Hello from the v2 implementation." }],
};
session.history.push(agentMessage);
await client.notify(v2.methods.client.session.update, {
sessionId: params.sessionId,
update: agentMessage,
});
await sendV2Update(params.sessionId, client, session, agentMessage);

signal.throwIfAborted();
session.state = { state: "idle", stopReason: "end_turn" };
await client.notify(v2.methods.client.session.update, {
sessionId: params.sessionId,
update: { sessionUpdate: "state_update", ...session.state },
await sendV2Update(params.sessionId, client, session, {
sessionUpdate: "state_update",
...session.state,
});
} catch (error) {
// Insertion already succeeded. Report failures on idle, not on the prompt RPC.
Expand All @@ -463,9 +509,9 @@ async function runV2Turn(
};

session.state = { state: "idle", ...completion };
await client.notify(v2.methods.client.session.update, {
sessionId: params.sessionId,
update: { sessionUpdate: "state_update", ...session.state },
await sendV2Update(params.sessionId, client, session, {
sessionUpdate: "state_update",
...session.state,
});
}
}
Loading
Loading