diff --git a/docs/contrib.md b/docs/contrib.md new file mode 100644 index 0000000..63be6a1 --- /dev/null +++ b/docs/contrib.md @@ -0,0 +1,40 @@ +# Experimental Contrib Modules + +> The helpers under `acp.contrib` capture patterns we observed in reference integrations such as Toad and kimi-cli. Every API here is experimental and may change without notice. + +## SessionAccumulator + +Module: `acp.contrib.session_state` + +UI surfaces like Toad need a live, merged view of the latest tool calls, plan entries, and message stream. The core SDK only emits raw `SessionNotification` payloads, so applications usually end up writing their own state layer. `SessionAccumulator` offers that cache out of the box. + +Capabilities: + +- `SessionAccumulator.apply(notification)` merges `tool_call` and `tool_call_update` events, backfilling a missing start message when necessary. +- Each call to `snapshot()` returns an immutable `SessionSnapshot` (Pydantic model) containing the active plan, current mode ID, available commands, and historical user/agent/thought chunks. +- `subscribe(callback)` wires a lightweight observer that receives every new snapshot, making it easy to refresh UI widgets. +- Automatic reset when a different session ID arrives (configurable via `auto_reset_on_session_change`). + +> Integration tip: create one accumulator per UI controller. Feed every `SessionNotification` through it, then render from `snapshot.tool_calls` or `snapshot.user_messages` instead of mutating state manually. + +## ToolCallTracker & PermissionBroker + +Modules: `acp.contrib.tool_calls` and `acp.contrib.permissions` + +Agent-side runtimes (for example kimi-cli) are responsible for synthesising tool call IDs, streaming argument fragments, and formatting permission prompts. Managing bare Pydantic models quickly devolves into boilerplate; these helpers centralise the bookkeeping. + +- `ToolCallTracker.start()/progress()/append_stream_text()` manages tool call state and emits canonical `ToolCallStart` / `ToolCallProgress` messages. The tracker also exposes `view()` (immutable `TrackedToolCallView`) and `tool_call_model()` for logging or permission prompts. +- `PermissionBroker.request_for()` wraps `requestPermission` RPCs. It reuses the tracker’s state (or an explicit `ToolCall`), applies optional extra content, and defaults to a standard Approve / Approve for session / Reject option set. +- `default_permission_options()` exposes that canonical option triple so applications can customise or extend it. + +> Integration tip: keep a single tracker alongside your agent loop. Emit tool call notifications through it, and hand the tracker to `PermissionBroker` so permission prompts stay in sync with the latest call state. + +## Design Guardrails + +To stay aligned with the ACP schema, the contrib layer follows a few rules: + +- Protocol types continue to live in `acp.schema`. Contrib code always copies them via `.model_copy(deep=True)` to avoid mutating shared instances. +- Helpers are opt-in; the core package never imports them implicitly and imposes no UI or agent framework assumptions. +- Implementations focus on the common pain points (tool call aggregation, permission requests) while leaving business-specific policy to the application. + +Try the contrib modules in your agent or client, and open an issue/PR with feedback so we can decide which pieces should graduate into the stable surface. diff --git a/mkdocs.yml b/mkdocs.yml index 8a027a8..f1704dd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,6 +10,7 @@ copyright: Maintained by psiace. nav: - Home: index.md - Quick Start: quickstart.md + - Experimental Contrib: contrib.md - Releasing: releasing.md plugins: - search diff --git a/pyproject.toml b/pyproject.toml index 107f8b8..c1bbaa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-client-protocol" -version = "0.4.9" +version = "0.5.0" description = "A Python implement of Agent Client Protocol (ACP, by Zed Industries)" authors = [{ name = "Chojan Shang", email = "psiace@apache.org" }] readme = "README.md" diff --git a/schema/VERSION b/schema/VERSION index 64ef92f..866919d 100644 --- a/schema/VERSION +++ b/schema/VERSION @@ -1 +1 @@ -refs/tags/v0.4.9 +refs/tags/v0.5.0 diff --git a/schema/schema.json b/schema/schema.json index d726f49..9c5e26a 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -46,6 +46,138 @@ "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Client`] trait instead.\n\nNotifications do not expect a response.", "x-docs-ignore": true }, + "AgentOutgoingMessage": { + "anyOf": [ + { + "properties": { + "id": { + "anyOf": [ + { + "title": "null", + "type": "null" + }, + { + "format": "int64", + "title": "number", + "type": "integer" + }, + { + "title": "string", + "type": "string" + } + ], + "description": "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions." + }, + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "$ref": "#/$defs/AgentRequest" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "method" + ], + "title": "Request", + "type": "object" + }, + { + "oneOf": [ + { + "properties": { + "result": { + "$ref": "#/$defs/AgentResponse" + } + }, + "required": [ + "result" + ], + "type": "object" + }, + { + "properties": { + "error": { + "$ref": "#/$defs/Error" + } + }, + "required": [ + "error" + ], + "type": "object" + } + ], + "properties": { + "id": { + "anyOf": [ + { + "title": "null", + "type": "null" + }, + { + "format": "int64", + "title": "number", + "type": "integer" + }, + { + "title": "string", + "type": "string" + } + ], + "description": "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions." + } + }, + "required": [ + "id" + ], + "title": "Response", + "type": "object" + }, + { + "properties": { + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "$ref": "#/$defs/AgentNotification" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ], + "title": "Notification", + "type": "object" + } + ], + "description": "A message (request, response, or notification) with `\"jsonrpc\": \"2.0\"` specified as\n[required by JSON-RPC 2.0 Specification][1].\n\n[1]: https://www.jsonrpc.org/specification#compatibility", + "properties": { + "jsonrpc": { + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "required": [ + "jsonrpc" + ], + "type": "object", + "x-docs-ignore": true + }, "AgentRequest": { "anyOf": [ { @@ -164,35 +296,6 @@ }, "type": "object" }, - "AudioContent": { - "description": "Audio provided to or from an LLM.", - "properties": { - "_meta": { - "description": "Extension point for implementations" - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/$defs/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "type": "string" - }, - "mimeType": { - "type": "string" - } - }, - "required": [ - "data", - "mimeType" - ], - "type": "object" - }, "AuthMethod": { "description": "Describes an available authentication method.", "properties": { @@ -385,6 +488,138 @@ "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Agent`] trait instead.\n\nNotifications do not expect a response.", "x-docs-ignore": true }, + "ClientOutgoingMessage": { + "anyOf": [ + { + "properties": { + "id": { + "anyOf": [ + { + "title": "null", + "type": "null" + }, + { + "format": "int64", + "title": "number", + "type": "integer" + }, + { + "title": "string", + "type": "string" + } + ], + "description": "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions." + }, + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "$ref": "#/$defs/ClientRequest" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "method" + ], + "title": "Request", + "type": "object" + }, + { + "oneOf": [ + { + "properties": { + "result": { + "$ref": "#/$defs/ClientResponse" + } + }, + "required": [ + "result" + ], + "type": "object" + }, + { + "properties": { + "error": { + "$ref": "#/$defs/Error" + } + }, + "required": [ + "error" + ], + "type": "object" + } + ], + "properties": { + "id": { + "anyOf": [ + { + "title": "null", + "type": "null" + }, + { + "format": "int64", + "title": "number", + "type": "integer" + }, + { + "title": "string", + "type": "string" + } + ], + "description": "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions." + } + }, + "required": [ + "id" + ], + "title": "Response", + "type": "object" + }, + { + "properties": { + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "$ref": "#/$defs/ClientNotification" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ], + "title": "Notification", + "type": "object" + } + ], + "description": "A message (request, response, or notification) with `\"jsonrpc\": \"2.0\"` specified as\n[required by JSON-RPC 2.0 Specification][1].\n\n[1]: https://www.jsonrpc.org/specification#compatibility", + "properties": { + "jsonrpc": { + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "required": [ + "jsonrpc" + ], + "type": "object", + "x-docs-ignore": true + }, "ClientRequest": { "anyOf": [ { @@ -740,31 +975,6 @@ "x-method": "terminal/create", "x-side": "client" }, - "EmbeddedResource": { - "description": "The contents of a resource, embedded into a prompt or tool call result.", - "properties": { - "_meta": { - "description": "Extension point for implementations" - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/$defs/Annotations" - }, - { - "type": "null" - } - ] - }, - "resource": { - "$ref": "#/$defs/EmbeddedResourceResource" - } - }, - "required": [ - "resource" - ], - "type": "object" - }, "EmbeddedResourceResource": { "anyOf": [ { @@ -799,6 +1009,28 @@ ], "type": "object" }, + "Error": { + "description": "JSON-RPC error object.\n\nRepresents an error that occurred during method execution, following the\nJSON-RPC 2.0 error object specification with optional additional data.\n\nSee protocol docs: [JSON-RPC Error Object](https://www.jsonrpc.org/specification#error_object)", + "properties": { + "code": { + "description": "A number indicating the error type that occurred.\nThis must be an integer as defined in the JSON-RPC specification.", + "format": "int32", + "type": "integer" + }, + "data": { + "description": "Optional primitive or structured value that contains additional information about the error.\nThis may include debugging information or context-specific details." + }, + "message": { + "description": "A string providing a short description of the error.\nThe message should be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, "FileSystemCapability": { "description": "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)", "properties": { @@ -839,41 +1071,6 @@ ], "type": "object" }, - "ImageContent": { - "description": "An image provided to or from an LLM.", - "properties": { - "_meta": { - "description": "Extension point for implementations" - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/$defs/Annotations" - }, - { - "type": "null" - } - ] - }, - "data": { - "type": "string" - }, - "mimeType": { - "type": "string" - }, - "uri": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "data", - "mimeType" - ], - "type": "object" - }, "InitializeRequest": { "description": "Request parameters for the initialize method.\n\nSent by the client to establish connection and negotiate capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", "properties": { @@ -1317,25 +1514,6 @@ } ] }, - "Plan": { - "description": "An execution plan for accomplishing complex tasks.\n\nPlans consist of multiple entries representing individual tasks or goals.\nAgents report plans to clients to provide visibility into their execution strategy.\nPlans can evolve during execution as the agent discovers new requirements or completes tasks.\n\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", - "properties": { - "_meta": { - "description": "Extension point for implementations" - }, - "entries": { - "description": "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.", - "items": { - "$ref": "#/$defs/PlanEntry" - }, - "type": "array" - } - }, - "required": [ - "entries" - ], - "type": "object" - }, "PlanEntry": { "description": "A single entry in the execution plan.\n\nRepresents a task or goal that the assistant intends to accomplish\nas part of fulfilling the user's request.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", "properties": { @@ -1623,8 +1801,75 @@ "description": "The session ID for this request." }, "toolCall": { - "$ref": "#/$defs/ToolCallUpdate", - "description": "Details about the tool call requiring permission." + "description": "Details about the tool call requiring permission.", + "properties": { + "_meta": { + "description": "Extension point for implementations" + }, + "content": { + "description": "Replace the content collection.", + "items": { + "$ref": "#/$defs/ToolCallContent" + }, + "type": [ + "array", + "null" + ] + }, + "kind": { + "anyOf": [ + { + "$ref": "#/$defs/ToolKind" + }, + { + "type": "null" + } + ], + "description": "Update the tool kind." + }, + "locations": { + "description": "Replace the locations collection.", + "items": { + "$ref": "#/$defs/ToolCallLocation" + }, + "type": [ + "array", + "null" + ] + }, + "rawInput": { + "description": "Update the raw input." + }, + "rawOutput": { + "description": "Update the raw output." + }, + "status": { + "anyOf": [ + { + "$ref": "#/$defs/ToolCallStatus" + }, + { + "type": "null" + } + ], + "description": "Update the execution status." + }, + "title": { + "description": "Update the human-readable title.", + "type": [ + "string", + "null" + ] + }, + "toolCallId": { + "$ref": "#/$defs/ToolCallId", + "description": "The ID of the tool call being updated." + } + }, + "required": [ + "toolCallId" + ], + "type": "object" } }, "required": [ @@ -1654,60 +1899,6 @@ "x-method": "session/request_permission", "x-side": "client" }, - "ResourceLink": { - "description": "A resource that the server is capable of reading, included in a prompt or tool call result.", - "properties": { - "_meta": { - "description": "Extension point for implementations" - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/$defs/Annotations" - }, - { - "type": "null" - } - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "mimeType": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "size": { - "format": "int64", - "type": [ - "integer", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "uri": { - "type": "string" - } - }, - "required": [ - "name", - "uri" - ], - "type": "object" - }, "Role": { "description": "The sender or recipient of messages and data in a conversation.", "enum": [ @@ -1826,8 +2017,12 @@ { "description": "A chunk of the user's message being streamed.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "content": { - "$ref": "#/$defs/ContentBlock" + "$ref": "#/$defs/ContentBlock", + "description": "A single item of content" }, "sessionUpdate": { "const": "user_message_chunk", @@ -1843,8 +2038,12 @@ { "description": "A chunk of the agent's response being streamed.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "content": { - "$ref": "#/$defs/ContentBlock" + "$ref": "#/$defs/ContentBlock", + "description": "A single item of content" }, "sessionUpdate": { "const": "agent_message_chunk", @@ -1860,8 +2059,12 @@ { "description": "A chunk of the agent's internal reasoning being streamed.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "content": { - "$ref": "#/$defs/ContentBlock" + "$ref": "#/$defs/ContentBlock", + "description": "A single item of content" }, "sessionUpdate": { "const": "agent_thought_chunk", @@ -2031,7 +2234,11 @@ { "description": "Available commands are ready or have changed", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "availableCommands": { + "description": "Commands the agent can execute", "items": { "$ref": "#/$defs/AvailableCommand" }, @@ -2051,8 +2258,12 @@ { "description": "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "currentModeId": { - "$ref": "#/$defs/SessionModeId" + "$ref": "#/$defs/SessionModeId", + "description": "The ID of the current mode" }, "sessionUpdate": { "const": "current_mode_update", @@ -2245,31 +2456,6 @@ "x-method": "terminal/output", "x-side": "client" }, - "TextContent": { - "description": "Text provided to or from an LLM.", - "properties": { - "_meta": { - "description": "Extension point for implementations" - }, - "annotations": { - "anyOf": [ - { - "$ref": "#/$defs/Annotations" - }, - { - "type": "null" - } - ] - }, - "text": { - "type": "string" - } - }, - "required": [ - "text" - ], - "type": "object" - }, "TextResourceContents": { "description": "Text-based resource contents.", "properties": { @@ -2295,55 +2481,6 @@ ], "type": "object" }, - "ToolCall": { - "description": "Represents a tool call that the language model has requested.\n\nTool calls are actions that the agent executes on behalf of the language model,\nsuch as reading files, executing code, or fetching data from external sources.\n\nSee protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)", - "properties": { - "_meta": { - "description": "Extension point for implementations" - }, - "content": { - "description": "Content produced by the tool call.", - "items": { - "$ref": "#/$defs/ToolCallContent" - }, - "type": "array" - }, - "kind": { - "$ref": "#/$defs/ToolKind", - "description": "The category of tool being invoked.\nHelps clients choose appropriate icons and UI treatment." - }, - "locations": { - "description": "File locations affected by this tool call.\nEnables \"follow-along\" features in clients.", - "items": { - "$ref": "#/$defs/ToolCallLocation" - }, - "type": "array" - }, - "rawInput": { - "description": "Raw input parameters sent to the tool." - }, - "rawOutput": { - "description": "Raw output returned by the tool." - }, - "status": { - "$ref": "#/$defs/ToolCallStatus", - "description": "Current execution status of the tool call." - }, - "title": { - "description": "Human-readable title describing what the tool is doing.", - "type": "string" - }, - "toolCallId": { - "$ref": "#/$defs/ToolCallId", - "description": "Unique identifier for this tool call within the session." - } - }, - "required": [ - "toolCallId", - "title" - ], - "type": "object" - }, "ToolCallContent": { "description": "Content produced by a tool call.\n\nTool calls can produce different types of content including\nstandard content blocks (text, images) or file diffs.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", "oneOf": [ @@ -2471,77 +2608,6 @@ } ] }, - "ToolCallUpdate": { - "description": "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)", - "properties": { - "_meta": { - "description": "Extension point for implementations" - }, - "content": { - "description": "Replace the content collection.", - "items": { - "$ref": "#/$defs/ToolCallContent" - }, - "type": [ - "array", - "null" - ] - }, - "kind": { - "anyOf": [ - { - "$ref": "#/$defs/ToolKind" - }, - { - "type": "null" - } - ], - "description": "Update the tool kind." - }, - "locations": { - "description": "Replace the locations collection.", - "items": { - "$ref": "#/$defs/ToolCallLocation" - }, - "type": [ - "array", - "null" - ] - }, - "rawInput": { - "description": "Update the raw input." - }, - "rawOutput": { - "description": "Update the raw output." - }, - "status": { - "anyOf": [ - { - "$ref": "#/$defs/ToolCallStatus" - }, - { - "type": "null" - } - ], - "description": "Update the execution status." - }, - "title": { - "description": "Update the human-readable title.", - "type": [ - "string", - "null" - ] - }, - "toolCallId": { - "$ref": "#/$defs/ToolCallId", - "description": "The ID of the tool call being updated." - } - }, - "required": [ - "toolCallId" - ], - "type": "object" - }, "ToolKind": { "description": "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)", "oneOf": [ @@ -2690,28 +2756,12 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "anyOf": [ { - "$ref": "#/$defs/AgentRequest", - "title": "ClientRequest" - }, - { - "$ref": "#/$defs/ClientResponse", - "title": "ClientResponse" - }, - { - "$ref": "#/$defs/ClientNotification", - "title": "ClientNotification" - }, - { - "$ref": "#/$defs/ClientRequest", - "title": "AgentRequest" - }, - { - "$ref": "#/$defs/AgentResponse", - "title": "AgentResponse" + "$ref": "#/$defs/AgentOutgoingMessage", + "title": "AgentOutgoingMessage" }, { - "$ref": "#/$defs/AgentNotification", - "title": "AgentNotification" + "$ref": "#/$defs/ClientOutgoingMessage", + "title": "ClientOutgoingMessage" } ] } diff --git a/scripts/gen_all.py b/scripts/gen_all.py index f6f1243..63e8d8a 100644 --- a/scripts/gen_all.py +++ b/scripts/gen_all.py @@ -21,7 +21,7 @@ META_JSON = SCHEMA_DIR / "meta.json" VERSION_FILE = SCHEMA_DIR / "VERSION" -DEFAULT_REPO = "zed-industries/agent-client-protocol" +DEFAULT_REPO = "agentclientprotocol/agent-client-protocol" def parse_args() -> argparse.Namespace: @@ -30,7 +30,7 @@ def parse_args() -> argparse.Namespace: "--version", "-v", help=( - "Git ref (tag/branch) of zed-industries/agent-client-protocol to fetch the schema from. " + "Git ref (tag/branch) of agentclientprotocol/agent-client-protocol to fetch the schema from. " "If omitted, uses the cached schema files or falls back to 'main' when missing." ), ) diff --git a/scripts/gen_schema.py b/scripts/gen_schema.py index 4df6c19..c61e3c3 100644 --- a/scripts/gen_schema.py +++ b/scripts/gen_schema.py @@ -28,7 +28,15 @@ # Map of numbered classes produced by datamodel-code-generator to descriptive names. # Keep this in sync with the Rust/TypeScript SDK nomenclature. RENAME_MAP: dict[str, str] = { + "AgentOutgoingMessage1": "AgentRequestMessage", + "AgentOutgoingMessage2": "AgentResponseMessage", + "AgentOutgoingMessage3": "AgentErrorMessage", + "AgentOutgoingMessage4": "AgentNotificationMessage", "AvailableCommandInput1": "CommandInputHint", + "ClientOutgoingMessage1": "ClientRequestMessage", + "ClientOutgoingMessage2": "ClientResponseMessage", + "ClientOutgoingMessage3": "ClientErrorMessage", + "ClientOutgoingMessage4": "ClientNotificationMessage", "ContentBlock1": "TextContentBlock", "ContentBlock2": "ImageContentBlock", "ContentBlock3": "AudioContentBlock", @@ -71,8 +79,6 @@ ("PlanEntry", "priority", "PlanEntryPriority", False), ("PlanEntry", "status", "PlanEntryStatus", False), ("PromptResponse", "stopReason", "StopReason", False), - ("ToolCallUpdate", "kind", "ToolKind", True), - ("ToolCallUpdate", "status", "ToolCallStatus", True), ("ToolCallProgress", "kind", "ToolKind", True), ("ToolCallProgress", "status", "ToolCallStatus", True), ("ToolCallStart", "kind", "ToolKind", True), diff --git a/src/acp/contrib/__init__.py b/src/acp/contrib/__init__.py new file mode 100644 index 0000000..21725fa --- /dev/null +++ b/src/acp/contrib/__init__.py @@ -0,0 +1,27 @@ +""" +Experimental helpers for Agent Client Protocol integrations. + +Everything exposed from :mod:`acp.contrib` is considered unstable and may change +without notice. These modules are published to share techniques observed in the +reference implementations (for example Toad or kimi-cli) while we continue +refining the core SDK surface. + +The helpers live in ``acp.contrib`` so consuming applications must opt-in +explicitly, making it clear that the APIs are incubating. +""" + +from __future__ import annotations + +from .permissions import PermissionBroker, default_permission_options +from .session_state import SessionAccumulator, SessionSnapshot, ToolCallView +from .tool_calls import ToolCallTracker, TrackedToolCallView + +__all__ = [ + "PermissionBroker", + "SessionAccumulator", + "SessionSnapshot", + "ToolCallTracker", + "ToolCallView", + "TrackedToolCallView", + "default_permission_options", +] diff --git a/src/acp/contrib/permissions.py b/src/acp/contrib/permissions.py new file mode 100644 index 0000000..5008092 --- /dev/null +++ b/src/acp/contrib/permissions.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Sequence +from typing import Any + +from ..helpers import text_block, tool_content +from ..schema import PermissionOption, RequestPermissionRequest, RequestPermissionResponse, ToolCall +from .tool_calls import ToolCallTracker, _copy_model_list + + +class PermissionBrokerError(ValueError): + """Base error for permission broker misconfiguration.""" + + +class MissingToolCallError(PermissionBrokerError): + """Raised when a permission request is missing the referenced tool call.""" + + def __init__(self) -> None: + super().__init__("tool_call must be provided when no ToolCallTracker is configured") + + +class MissingPermissionOptionsError(PermissionBrokerError): + """Raised when no permission options are available for a request.""" + + def __init__(self) -> None: + super().__init__("PermissionBroker requires at least one permission option") + + +def default_permission_options() -> tuple[PermissionOption, PermissionOption, PermissionOption]: + """Return a standard approval/reject option set.""" + return ( + PermissionOption(optionId="approve", name="Approve", kind="allow_once"), + PermissionOption(optionId="approve_for_session", name="Approve for session", kind="allow_always"), + PermissionOption(optionId="reject", name="Reject", kind="reject_once"), + ) + + +class PermissionBroker: + """Helper for issuing permission requests tied to tracked tool calls.""" + + def __init__( + self, + session_id: str, + requester: Callable[[RequestPermissionRequest], Awaitable[RequestPermissionResponse]], + *, + tracker: ToolCallTracker | None = None, + default_options: Sequence[PermissionOption] | None = None, + ) -> None: + self._session_id = session_id + self._requester = requester + self._tracker = tracker + self._default_options = tuple( + option.model_copy(deep=True) for option in (default_options or default_permission_options()) + ) + + async def request_for( + self, + external_id: str, + *, + description: str | None = None, + options: Sequence[PermissionOption] | None = None, + content: Sequence[Any] | None = None, + tool_call: ToolCall | None = None, + ) -> RequestPermissionResponse: + """Request user approval for a tool call.""" + if tool_call is None: + if self._tracker is None: + raise MissingToolCallError() + tool_call = self._tracker.tool_call_model(external_id) + else: + tool_call = tool_call.model_copy(deep=True) + + if content is not None: + tool_call.content = _copy_model_list(content) + + if description: + existing = tool_call.content or [] + existing.append(tool_content(text_block(description))) + tool_call.content = existing + + option_set = tuple(option.model_copy(deep=True) for option in (options or self._default_options)) + if not option_set: + raise MissingPermissionOptionsError() + + request = RequestPermissionRequest( + sessionId=self._session_id, + toolCall=tool_call, + options=list(option_set), + ) + return await self._requester(request) + + +__all__ = [ + "PermissionBroker", + "default_permission_options", +] diff --git a/src/acp/contrib/session_state.py b/src/acp/contrib/session_state.py new file mode 100644 index 0000000..7933be6 --- /dev/null +++ b/src/acp/contrib/session_state.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from contextlib import suppress +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from ..schema import ( + AgentMessageChunk, + AgentPlanUpdate, + AgentThoughtChunk, + AvailableCommand, + AvailableCommandsUpdate, + CurrentModeUpdate, + PlanEntry, + SessionNotification, + ToolCallLocation, + ToolCallProgress, + ToolCallStart, + ToolCallStatus, + ToolKind, + UserMessageChunk, +) + + +class SessionNotificationMismatchError(ValueError): + """Raised when the accumulator receives notifications from a different session.""" + + def __init__(self, expected: str, actual: str) -> None: + message = f"SessionAccumulator received notification for {actual}, expected {expected}" + super().__init__(message) + + +class SessionSnapshotUnavailableError(RuntimeError): + """Raised when a session snapshot is requested before any notifications.""" + + def __init__(self) -> None: + super().__init__("SessionAccumulator has not processed any notifications yet") + + +def _copy_model_list(items: Sequence[Any] | None) -> list[Any] | None: + if items is None: + return None + return [item.model_copy(deep=True) for item in items] + + +class _MutableToolCallState: + def __init__(self, tool_call_id: str) -> None: + self.tool_call_id = tool_call_id + self.title: str | None = None + self.kind: ToolKind | None = None + self.status: ToolCallStatus | None = None + self.content: list[Any] | None = None + self.locations: list[ToolCallLocation] | None = None + self.raw_input: Any = None + self.raw_output: Any = None + + def apply_start(self, update: ToolCallStart) -> None: + self.title = update.title + self.kind = update.kind + self.status = update.status + self.content = _copy_model_list(update.content) + self.locations = _copy_model_list(update.locations) + self.raw_input = update.rawInput + self.raw_output = update.rawOutput + + def apply_progress(self, update: ToolCallProgress) -> None: + if update.title is not None: + self.title = update.title + if update.kind is not None: + self.kind = update.kind + if update.status is not None: + self.status = update.status + if update.content is not None: + self.content = _copy_model_list(update.content) + if update.locations is not None: + self.locations = _copy_model_list(update.locations) + if update.rawInput is not None: + self.raw_input = update.rawInput + if update.rawOutput is not None: + self.raw_output = update.rawOutput + + def snapshot(self) -> ToolCallView: + return ToolCallView( + tool_call_id=self.tool_call_id, + title=self.title, + kind=self.kind, + status=self.status, + content=tuple(item.model_copy(deep=True) for item in self.content) if self.content else None, + locations=tuple(loc.model_copy(deep=True) for loc in self.locations) if self.locations else None, + raw_input=self.raw_input, + raw_output=self.raw_output, + ) + + +class ToolCallView(BaseModel): + """Immutable view of a tool call in the session.""" + + model_config = ConfigDict(frozen=True) + + tool_call_id: str + title: str | None + kind: ToolKind | None + status: ToolCallStatus | None + content: tuple[Any, ...] | None + locations: tuple[ToolCallLocation, ...] | None + raw_input: Any + raw_output: Any + + +class SessionSnapshot(BaseModel): + """Aggregated snapshot of the most recent session state.""" + + model_config = ConfigDict(frozen=True) + + session_id: str + tool_calls: dict[str, ToolCallView] + plan_entries: tuple[PlanEntry, ...] + current_mode_id: str | None + available_commands: tuple[AvailableCommand, ...] + user_messages: tuple[UserMessageChunk, ...] + agent_messages: tuple[AgentMessageChunk, ...] + agent_thoughts: tuple[AgentThoughtChunk, ...] + + +class SessionAccumulator: + """Merge :class:`acp.schema.SessionNotification` objects into a session snapshot. + + The accumulator focuses on the common requirements observed in the Toad UI: + + * Always expose the latest merged tool call state (even if updates arrive + without a matching ``tool_call`` start). + * Track the agent plan, available commands, and current mode id. + * Record the raw stream of user/agent message chunks for UI rendering. + + This helper is **experimental**: APIs may change while we gather feedback. + """ + + def __init__(self, *, auto_reset_on_session_change: bool = True) -> None: + self._auto_reset = auto_reset_on_session_change + self.session_id: str | None = None + self._tool_calls: dict[str, _MutableToolCallState] = {} + self._plan_entries: list[PlanEntry] = [] + self._current_mode_id: str | None = None + self._available_commands: list[AvailableCommand] = [] + self._user_messages: list[UserMessageChunk] = [] + self._agent_messages: list[AgentMessageChunk] = [] + self._agent_thoughts: list[AgentThoughtChunk] = [] + self._subscribers: list[Callable[[SessionSnapshot, SessionNotification], None]] = [] + + def reset(self) -> None: + """Clear all accumulated state.""" + self.session_id = None + self._tool_calls.clear() + self._plan_entries.clear() + self._current_mode_id = None + self._available_commands.clear() + self._user_messages.clear() + self._agent_messages.clear() + self._agent_thoughts.clear() + + def subscribe(self, callback: Callable[[SessionSnapshot, SessionNotification], None]) -> Callable[[], None]: + """Register a callback that receives every new snapshot. + + The callback is invoked immediately after :meth:`apply` finishes. The + function returns an ``unsubscribe`` callable. + """ + + self._subscribers.append(callback) + + def unsubscribe() -> None: + with suppress(ValueError): + self._subscribers.remove(callback) + + return unsubscribe + + def apply(self, notification: SessionNotification) -> SessionSnapshot: + """Merge a new session notification into the current snapshot.""" + self._ensure_session(notification) + self._apply_update(notification.update) + snapshot = self.snapshot() + self._notify_subscribers(snapshot, notification) + return snapshot + + def _ensure_session(self, notification: SessionNotification) -> None: + if self.session_id is None: + self.session_id = notification.sessionId + return + + if notification.sessionId != self.session_id: + self._handle_session_change(notification.sessionId) + + def _handle_session_change(self, session_id: str) -> None: + expected = self.session_id + if expected is None: + self.session_id = session_id + return + + if not self._auto_reset: + raise SessionNotificationMismatchError(expected, session_id) + + self.reset() + self.session_id = session_id + + def _apply_update(self, update: Any) -> None: + if isinstance(update, ToolCallStart): + state = self._tool_calls.setdefault( + update.toolCallId, _MutableToolCallState(tool_call_id=update.toolCallId) + ) + state.apply_start(update) + return + + if isinstance(update, ToolCallProgress): + state = self._tool_calls.setdefault( + update.toolCallId, _MutableToolCallState(tool_call_id=update.toolCallId) + ) + state.apply_progress(update) + return + + if isinstance(update, AgentPlanUpdate): + self._plan_entries = _copy_model_list(update.entries) or [] + return + + if isinstance(update, CurrentModeUpdate): + self._current_mode_id = update.currentModeId + return + + if isinstance(update, AvailableCommandsUpdate): + self._available_commands = _copy_model_list(update.availableCommands) or [] + return + + if isinstance(update, UserMessageChunk): + self._user_messages.append(update.model_copy(deep=True)) + return + + if isinstance(update, AgentMessageChunk): + self._agent_messages.append(update.model_copy(deep=True)) + return + + if isinstance(update, AgentThoughtChunk): + self._agent_thoughts.append(update.model_copy(deep=True)) + + def _notify_subscribers( + self, + snapshot: SessionSnapshot, + notification: SessionNotification, + ) -> None: + for callback in list(self._subscribers): + callback(snapshot, notification) + + def snapshot(self) -> SessionSnapshot: + """Return an immutable snapshot of the current state.""" + if self.session_id is None: + raise SessionSnapshotUnavailableError() + + tool_calls = {tool_call_id: state.snapshot() for tool_call_id, state in self._tool_calls.items()} + plan_entries = tuple(entry.model_copy(deep=True) for entry in self._plan_entries) + available_commands = tuple(command.model_copy(deep=True) for command in self._available_commands) + user_messages = tuple(message.model_copy(deep=True) for message in self._user_messages) + agent_messages = tuple(message.model_copy(deep=True) for message in self._agent_messages) + agent_thoughts = tuple(message.model_copy(deep=True) for message in self._agent_thoughts) + + return SessionSnapshot( + session_id=self.session_id, + tool_calls=tool_calls, + plan_entries=plan_entries, + current_mode_id=self._current_mode_id, + available_commands=available_commands, + user_messages=user_messages, + agent_messages=agent_messages, + agent_thoughts=agent_thoughts, + ) diff --git a/src/acp/contrib/tool_calls.py b/src/acp/contrib/tool_calls.py new file mode 100644 index 0000000..5907485 --- /dev/null +++ b/src/acp/contrib/tool_calls.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import uuid +from collections.abc import Callable, Sequence +from typing import Any, cast + +from pydantic import BaseModel, ConfigDict + +from ..helpers import text_block, tool_content +from ..schema import ToolCall, ToolCallLocation, ToolCallProgress, ToolCallStart, ToolCallStatus, ToolKind + + +class _MissingToolCallTitleError(ValueError): + """Raised when emitting a tool call start without a configured title.""" + + def __init__(self) -> None: + super().__init__("title must be set before sending a ToolCallStart") + + +class _UnknownToolCallError(KeyError): + """Raised when retrieving a tool call that is not tracked.""" + + def __init__(self, external_id: str) -> None: + self.external_id = external_id + super().__init__(external_id) + + def __str__(self) -> str: + return f"Unknown tool call id: {self.external_id}" + + +def _copy_model_list(items: Sequence[Any] | None) -> list[Any] | None: + if items is None: + return None + return [item.model_copy(deep=True) for item in items] + + +class _Unset: + """Sentinel for optional parameters.""" + + +UNSET = _Unset() + + +class TrackedToolCallView(BaseModel): + """Immutable representation of a tracked tool call.""" + + model_config = ConfigDict(frozen=True) + + tool_call_id: str + title: str | None + kind: ToolKind | None + status: ToolCallStatus | None + content: tuple[Any, ...] | None + locations: tuple[ToolCallLocation, ...] | None + raw_input: Any + raw_output: Any + + +class _TrackedToolCall: + def __init__( + self, + *, + tool_call_id: str, + title: str | None = None, + kind: ToolKind | None = None, + status: ToolCallStatus | None = None, + content: Sequence[Any] | None = None, + locations: Sequence[ToolCallLocation] | None = None, + raw_input: Any = None, + raw_output: Any = None, + ) -> None: + self.tool_call_id = tool_call_id + self.title = title + self.kind = kind + self.status = status + self.content = _copy_model_list(content) + self.locations = _copy_model_list(locations) + self.raw_input = raw_input + self.raw_output = raw_output + self._stream_buffer: str | None = None + + def to_view(self) -> TrackedToolCallView: + return TrackedToolCallView( + tool_call_id=self.tool_call_id, + title=self.title, + kind=self.kind, + status=self.status, + content=tuple(item.model_copy(deep=True) for item in self.content) if self.content else None, + locations=tuple(loc.model_copy(deep=True) for loc in self.locations) if self.locations else None, + raw_input=self.raw_input, + raw_output=self.raw_output, + ) + + def to_tool_call_model(self) -> ToolCall: + return ToolCall( + toolCallId=self.tool_call_id, + title=self.title, + kind=self.kind, + status=self.status, + content=_copy_model_list(self.content), + locations=_copy_model_list(self.locations), + rawInput=self.raw_input, + rawOutput=self.raw_output, + ) + + def to_start_model(self) -> ToolCallStart: + if self.title is None: + raise _MissingToolCallTitleError() + return ToolCallStart( + sessionUpdate="tool_call", + toolCallId=self.tool_call_id, + title=self.title, + kind=self.kind, + status=self.status, + content=_copy_model_list(self.content), + locations=_copy_model_list(self.locations), + rawInput=self.raw_input, + rawOutput=self.raw_output, + ) + + def update( + self, + *, + title: Any = UNSET, + kind: Any = UNSET, + status: Any = UNSET, + content: Any = UNSET, + locations: Any = UNSET, + raw_input: Any = UNSET, + raw_output: Any = UNSET, + ) -> ToolCallProgress: + kwargs: dict[str, Any] = {} + if title is not UNSET: + self.title = cast(str | None, title) + kwargs["title"] = self.title + if kind is not UNSET: + self.kind = cast(ToolKind | None, kind) + kwargs["kind"] = self.kind + if status is not UNSET: + self.status = cast(ToolCallStatus | None, status) + kwargs["status"] = self.status + if content is not UNSET: + seq_content = cast(Sequence[Any] | None, content) + self.content = _copy_model_list(seq_content) + kwargs["content"] = _copy_model_list(seq_content) + if locations is not UNSET: + seq_locations = cast(Sequence[ToolCallLocation] | None, locations) + self.locations = cast( + list[ToolCallLocation] | None, + _copy_model_list(seq_locations), + ) + kwargs["locations"] = _copy_model_list(seq_locations) + if raw_input is not UNSET: + self.raw_input = raw_input + kwargs["rawInput"] = raw_input + if raw_output is not UNSET: + self.raw_output = raw_output + kwargs["rawOutput"] = raw_output + return ToolCallProgress(sessionUpdate="tool_call_update", toolCallId=self.tool_call_id, **kwargs) + + def append_stream_text( + self, + text: str, + *, + title: Any = UNSET, + status: Any = UNSET, + ) -> ToolCallProgress: + self._stream_buffer = (self._stream_buffer or "") + text + content = [tool_content(text_block(self._stream_buffer))] + return self.update(title=title, status=status, content=content) + + +class ToolCallTracker: + """Utility for generating ACP tool call updates on the server side.""" + + def __init__(self, *, id_factory: Callable[[], str] | None = None) -> None: + self._id_factory = id_factory or (lambda: uuid.uuid4().hex) + self._calls: dict[str, _TrackedToolCall] = {} + + def start( + self, + external_id: str, + *, + title: str, + kind: ToolKind | None = None, + status: ToolCallStatus | None = "in_progress", + content: Sequence[Any] | None = None, + locations: Sequence[ToolCallLocation] | None = None, + raw_input: Any = None, + raw_output: Any = None, + ) -> ToolCallStart: + """Register a new tool call and return the ``tool_call`` notification.""" + call_id = self._id_factory() + state = _TrackedToolCall( + tool_call_id=call_id, + title=title, + kind=kind, + status=status, + content=content, + locations=locations, + raw_input=raw_input, + raw_output=raw_output, + ) + self._calls[external_id] = state + return state.to_start_model() + + def progress( + self, + external_id: str, + *, + title: Any = UNSET, + kind: Any = UNSET, + status: Any = UNSET, + content: Any = UNSET, + locations: Any = UNSET, + raw_input: Any = UNSET, + raw_output: Any = UNSET, + ) -> ToolCallProgress: + """Produce a ``tool_call_update`` message and merge it into the tracker.""" + state = self._require_call(external_id) + return state.update( + title=title, + kind=kind, + status=status, + content=content, + locations=locations, + raw_input=raw_input, + raw_output=raw_output, + ) + + def append_stream_text( + self, + external_id: str, + text: str, + *, + title: Any = UNSET, + status: Any = UNSET, + ) -> ToolCallProgress: + """Append text to the tool call arguments/content and emit an update.""" + state = self._require_call(external_id) + return state.append_stream_text(text, title=title, status=status) + + def forget(self, external_id: str) -> None: + """Remove a tracked tool call (e.g. after completion).""" + self._calls.pop(external_id, None) + + def view(self, external_id: str) -> TrackedToolCallView: + """Return an immutable view of the current tool call state.""" + state = self._require_call(external_id) + return state.to_view() + + def tool_call_model(self, external_id: str) -> ToolCall: + """Return a deep copy of the tool call suitable for permission requests.""" + state = self._require_call(external_id) + return state.to_tool_call_model() + + def _require_call(self, external_id: str) -> _TrackedToolCall: + try: + return self._calls[external_id] + except KeyError as exc: + raise _UnknownToolCallError(external_id) from exc + + +__all__ = [ + "UNSET", + "ToolCallTracker", + "TrackedToolCallView", +] diff --git a/src/acp/helpers.py b/src/acp/helpers.py index 8514da2..d5a473f 100644 --- a/src/acp/helpers.py +++ b/src/acp/helpers.py @@ -8,9 +8,11 @@ AgentPlanUpdate, AgentThoughtChunk, AudioContentBlock, + AvailableCommand, + AvailableCommandsUpdate, BlobResourceContents, ContentToolCallContent, - EmbeddedResource, + CurrentModeUpdate, EmbeddedResourceContentBlock, FileEditToolCallContent, ImageContentBlock, @@ -35,7 +37,14 @@ ) SessionUpdate = ( - AgentMessageChunk | AgentPlanUpdate | AgentThoughtChunk | UserMessageChunk | ToolCallStart | ToolCallProgress + AgentMessageChunk + | AgentPlanUpdate + | AgentThoughtChunk + | AvailableCommandsUpdate + | CurrentModeUpdate + | UserMessageChunk + | ToolCallStart + | ToolCallProgress ) ToolCallContentVariant = ContentToolCallContent | FileEditToolCallContent | TerminalToolCallContent @@ -60,6 +69,8 @@ "update_agent_message_text", "update_agent_thought", "update_agent_thought_text", + "update_available_commands", + "update_current_mode", "update_plan", "update_tool_call", "update_user_message", @@ -99,19 +110,18 @@ def resource_link_block( ) -def embedded_text_resource(uri: str, text: str, *, mime_type: str | None = None) -> EmbeddedResource: - return EmbeddedResource(resource=TextResourceContents(uri=uri, text=text, mimeType=mime_type)) +def embedded_text_resource(uri: str, text: str, *, mime_type: str | None = None) -> TextResourceContents: + return TextResourceContents(uri=uri, text=text, mimeType=mime_type) -def embedded_blob_resource(uri: str, blob: str, *, mime_type: str | None = None) -> EmbeddedResource: - return EmbeddedResource(resource=BlobResourceContents(uri=uri, blob=blob, mimeType=mime_type)) +def embedded_blob_resource(uri: str, blob: str, *, mime_type: str | None = None) -> BlobResourceContents: + return BlobResourceContents(uri=uri, blob=blob, mimeType=mime_type) def resource_block( - resource: EmbeddedResource | TextResourceContents | BlobResourceContents, + resource: TextResourceContents | BlobResourceContents, ) -> EmbeddedResourceContentBlock: - resource_obj = resource.resource if isinstance(resource, EmbeddedResource) else resource - return EmbeddedResourceContentBlock(type="resource", resource=resource_obj) + return EmbeddedResourceContentBlock(type="resource", resource=resource) def tool_content(block: ContentBlock) -> ContentToolCallContent: @@ -163,6 +173,17 @@ def update_agent_thought_text(text: str) -> AgentThoughtChunk: return update_agent_thought(text_block(text)) +def update_available_commands(commands: Iterable[AvailableCommand]) -> AvailableCommandsUpdate: + return AvailableCommandsUpdate( + sessionUpdate="available_commands_update", + availableCommands=list(commands), + ) + + +def update_current_mode(current_mode_id: str) -> CurrentModeUpdate: + return CurrentModeUpdate(sessionUpdate="current_mode_update", currentModeId=current_mode_id) + + def session_notification(session_id: str, update: SessionUpdate) -> SessionNotification: return SessionNotification(sessionId=session_id, update=update) diff --git a/src/acp/meta.py b/src/acp/meta.py index f436615..f9b189f 100644 --- a/src/acp/meta.py +++ b/src/acp/meta.py @@ -1,5 +1,5 @@ # Generated from schema/meta.json. Do not edit by hand. -# Schema ref: refs/tags/v0.4.9 +# Schema ref: refs/tags/v0.5.0 AGENT_METHODS = {'authenticate': 'authenticate', 'initialize': 'initialize', 'session_cancel': 'session/cancel', 'session_load': 'session/load', 'session_new': 'session/new', 'session_prompt': 'session/prompt', 'session_set_mode': 'session/set_mode', 'session_set_model': 'session/set_model'} CLIENT_METHODS = {'fs_read_text_file': 'fs/read_text_file', 'fs_write_text_file': 'fs/write_text_file', 'session_request_permission': 'session/request_permission', 'session_update': 'session/update', 'terminal_create': 'terminal/create', 'terminal_kill': 'terminal/kill', 'terminal_output': 'terminal/output', 'terminal_release': 'terminal/release', 'terminal_wait_for_exit': 'terminal/wait_for_exit'} PROTOCOL_VERSION = 1 diff --git a/src/acp/schema.py b/src/acp/schema.py index 3f5386e..9050d15 100644 --- a/src/acp/schema.py +++ b/src/acp/schema.py @@ -1,5 +1,5 @@ # Generated from schema/schema.json. Do not edit by hand. -# Schema ref: refs/tags/v0.4.9 +# Schema ref: refs/tags/v0.5.0 from __future__ import annotations @@ -17,6 +17,10 @@ ToolKind = Literal["read", "edit", "delete", "move", "search", "execute", "think", "fetch", "switch_mode", "other"] +class Jsonrpc(Enum): + field_2_0 = "2.0" + + class AuthenticateRequest(BaseModel): # Extension point for implementations field_meta: Annotated[ @@ -90,6 +94,33 @@ class EnvVariable(BaseModel): value: Annotated[str, Field(description="The value to set for the environment variable.")] +class Error(BaseModel): + # A number indicating the error type that occurred. + # This must be an integer as defined in the JSON-RPC specification. + code: Annotated[ + int, + Field( + description="A number indicating the error type that occurred.\nThis must be an integer as defined in the JSON-RPC specification." + ), + ] + # Optional primitive or structured value that contains additional information about the error. + # This may include debugging information or context-specific details. + data: Annotated[ + Optional[Any], + Field( + description="Optional primitive or structured value that contains additional information about the error.\nThis may include debugging information or context-specific details." + ), + ] = None + # A string providing a short description of the error. + # The message should be limited to a concise single sentence. + message: Annotated[ + str, + Field( + description="A string providing a short description of the error.\nThe message should be limited to a concise single sentence." + ), + ] + + class FileSystemCapability(BaseModel): # Extension point for implementations field_meta: Annotated[ @@ -295,8 +326,13 @@ class SessionModelState(BaseModel): class CurrentModeUpdate(BaseModel): - # Unique identifier for a Session Mode. - currentModeId: Annotated[str, Field(description="Unique identifier for a Session Mode.")] + # Extension point for implementations + field_meta: Annotated[ + Optional[Any], + Field(alias="_meta", description="Extension point for implementations"), + ] = None + # The ID of the current mode + currentModeId: Annotated[str, Field(description="The ID of the current mode")] sessionUpdate: Literal["current_mode_update"] @@ -504,6 +540,26 @@ class AgentCapabilities(BaseModel): ] = PromptCapabilities(audio=False, embeddedContext=False, image=False) +class AgentErrorMessage(BaseModel): + jsonrpc: Jsonrpc + # JSON RPC Request Id + # + # An identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2] + # + # The Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects. + # + # [1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling. + # + # [2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions. + id: Annotated[ + Optional[Union[int, str]], + Field( + description="JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions." + ), + ] = None + error: Error + + class Annotations(BaseModel): # Extension point for implementations field_meta: Annotated[ @@ -515,17 +571,6 @@ class Annotations(BaseModel): priority: Optional[float] = None -class AudioContent(BaseModel): - # Extension point for implementations - field_meta: Annotated[ - Optional[Any], - Field(alias="_meta", description="Extension point for implementations"), - ] = None - annotations: Optional[Annotations] = None - data: str - mimeType: str - - class AuthMethod(BaseModel): # Extension point for implementations field_meta: Annotated[ @@ -594,6 +639,32 @@ class ClientCapabilities(BaseModel): ] = False +class ClientErrorMessage(BaseModel): + jsonrpc: Jsonrpc + # JSON RPC Request Id + # + # An identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2] + # + # The Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects. + # + # [1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling. + # + # [2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions. + id: Annotated[ + Optional[Union[int, str]], + Field( + description="JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions." + ), + ] = None + error: Error + + +class ClientNotificationMessage(BaseModel): + jsonrpc: Jsonrpc + method: str + params: Optional[Union[CancelNotification, Any]] = None + + class TextContentBlock(BaseModel): # Extension point for implementations field_meta: Annotated[ @@ -685,18 +756,6 @@ class CreateTerminalRequest(BaseModel): sessionId: Annotated[str, Field(description="The session ID for this request.")] -class ImageContent(BaseModel): - # Extension point for implementations - field_meta: Annotated[ - Optional[Any], - Field(alias="_meta", description="Extension point for implementations"), - ] = None - annotations: Optional[Annotations] = None - data: str - mimeType: str - uri: Optional[str] = None - - class InitializeRequest(BaseModel): # Extension point for implementations field_meta: Annotated[ @@ -860,21 +919,6 @@ class ReleaseTerminalRequest(BaseModel): terminalId: Annotated[str, Field(description="The ID of the terminal to release.")] -class ResourceLink(BaseModel): - # Extension point for implementations - field_meta: Annotated[ - Optional[Any], - Field(alias="_meta", description="Extension point for implementations"), - ] = None - annotations: Optional[Annotations] = None - description: Optional[str] = None - mimeType: Optional[str] = None - name: str - size: Optional[int] = None - title: Optional[str] = None - uri: str - - class SessionMode(BaseModel): # Extension point for implementations field_meta: Annotated[ @@ -922,36 +966,58 @@ class AgentPlanUpdate(BaseModel): class AvailableCommandsUpdate(BaseModel): - availableCommands: List[AvailableCommand] - sessionUpdate: Literal["available_commands_update"] - - -class TextContent(BaseModel): # Extension point for implementations field_meta: Annotated[ Optional[Any], Field(alias="_meta", description="Extension point for implementations"), ] = None - annotations: Optional[Annotations] = None - text: str + # Commands the agent can execute + availableCommands: Annotated[List[AvailableCommand], Field(description="Commands the agent can execute")] + sessionUpdate: Literal["available_commands_update"] -class EmbeddedResourceContentBlock(BaseModel): - # Extension point for implementations - field_meta: Annotated[ - Optional[Any], - Field(alias="_meta", description="Extension point for implementations"), +class ClientResponseMessage(BaseModel): + jsonrpc: Jsonrpc + # JSON RPC Request Id + # + # An identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2] + # + # The Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects. + # + # [1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling. + # + # [2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions. + id: Annotated[ + Optional[Union[int, str]], + Field( + description="JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions." + ), ] = None - annotations: Optional[Annotations] = None - # Resource content that can be embedded in a message. - resource: Annotated[ - Union[TextResourceContents, BlobResourceContents], - Field(description="Resource content that can be embedded in a message."), + # All possible responses that a client can send to an agent. + # + # This enum is used internally for routing RPC responses. You typically won't need + # to use this directly - the responses are handled automatically by the connection. + # + # These are responses to the corresponding `AgentRequest` variants. + result: Annotated[ + Union[ + WriteTextFileResponse, + ReadTextFileResponse, + RequestPermissionResponse, + CreateTerminalResponse, + TerminalOutputResponse, + ReleaseTerminalResponse, + WaitForTerminalExitResponse, + KillTerminalCommandResponse, + Any, + ], + Field( + description="All possible responses that a client can send to an agent.\n\nThis enum is used internally for routing RPC responses. You typically won't need\nto use this directly - the responses are handled automatically by the connection.\n\nThese are responses to the corresponding `AgentRequest` variants." + ), ] - type: Literal["resource"] -class EmbeddedResource(BaseModel): +class EmbeddedResourceContentBlock(BaseModel): # Extension point for implementations field_meta: Annotated[ Optional[Any], @@ -963,6 +1029,7 @@ class EmbeddedResource(BaseModel): Union[TextResourceContents, BlobResourceContents], Field(description="Resource content that can be embedded in a message."), ] + type: Literal["resource"] class LoadSessionResponse(BaseModel): @@ -1030,24 +1097,6 @@ class NewSessionResponse(BaseModel): ] -class Plan(BaseModel): - # Extension point for implementations - field_meta: Annotated[ - Optional[Any], - Field(alias="_meta", description="Extension point for implementations"), - ] = None - # The list of tasks to be accomplished. - # - # When updating a plan, the agent must send a complete list of all entries - # with their current status. The client replaces the entire plan with each update. - entries: Annotated[ - List[PlanEntry], - Field( - description="The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update." - ), - ] - - class PromptRequest(BaseModel): # Extension point for implementations field_meta: Annotated[ @@ -1086,79 +1135,49 @@ class PromptRequest(BaseModel): class UserMessageChunk(BaseModel): - # Content blocks represent displayable information in the Agent Client Protocol. - # - # They provide a structured way to handle various types of user-facing content—whether - # it's text from language models, images for analysis, or embedded resources for context. - # - # Content blocks appear in: - # - User prompts sent via `session/prompt` - # - Language model output streamed through `session/update` notifications - # - Progress updates and results from tool calls - # - # This structure is compatible with the Model Context Protocol (MCP), enabling - # agents to seamlessly forward content from MCP tool outputs without transformation. - # - # See protocol docs: [Content](https://agentclientprotocol.com/protocol/content) + # Extension point for implementations + field_meta: Annotated[ + Optional[Any], + Field(alias="_meta", description="Extension point for implementations"), + ] = None + # A single item of content content: Annotated[ Union[ TextContentBlock, ImageContentBlock, AudioContentBlock, ResourceContentBlock, EmbeddedResourceContentBlock ], - Field( - description="Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)" - ), + Field(description="A single item of content"), ] sessionUpdate: Literal["user_message_chunk"] class AgentMessageChunk(BaseModel): - # Content blocks represent displayable information in the Agent Client Protocol. - # - # They provide a structured way to handle various types of user-facing content—whether - # it's text from language models, images for analysis, or embedded resources for context. - # - # Content blocks appear in: - # - User prompts sent via `session/prompt` - # - Language model output streamed through `session/update` notifications - # - Progress updates and results from tool calls - # - # This structure is compatible with the Model Context Protocol (MCP), enabling - # agents to seamlessly forward content from MCP tool outputs without transformation. - # - # See protocol docs: [Content](https://agentclientprotocol.com/protocol/content) + # Extension point for implementations + field_meta: Annotated[ + Optional[Any], + Field(alias="_meta", description="Extension point for implementations"), + ] = None + # A single item of content content: Annotated[ Union[ TextContentBlock, ImageContentBlock, AudioContentBlock, ResourceContentBlock, EmbeddedResourceContentBlock ], - Field( - description="Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)" - ), + Field(description="A single item of content"), ] sessionUpdate: Literal["agent_message_chunk"] class AgentThoughtChunk(BaseModel): - # Content blocks represent displayable information in the Agent Client Protocol. - # - # They provide a structured way to handle various types of user-facing content—whether - # it's text from language models, images for analysis, or embedded resources for context. - # - # Content blocks appear in: - # - User prompts sent via `session/prompt` - # - Language model output streamed through `session/update` notifications - # - Progress updates and results from tool calls - # - # This structure is compatible with the Model Context Protocol (MCP), enabling - # agents to seamlessly forward content from MCP tool outputs without transformation. - # - # See protocol docs: [Content](https://agentclientprotocol.com/protocol/content) + # Extension point for implementations + field_meta: Annotated[ + Optional[Any], + Field(alias="_meta", description="Extension point for implementations"), + ] = None + # A single item of content content: Annotated[ Union[ TextContentBlock, ImageContentBlock, AudioContentBlock, ResourceContentBlock, EmbeddedResourceContentBlock ], - Field( - description="Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)" - ), + Field(description="A single item of content"), ] sessionUpdate: Literal["agent_thought_chunk"] @@ -1174,7 +1193,7 @@ class ContentToolCallContent(BaseModel): type: Literal["content"] -class ToolCallUpdate(BaseModel): +class ToolCall(BaseModel): # Extension point for implementations field_meta: Annotated[ Optional[Any], @@ -1218,10 +1237,7 @@ class RequestPermissionRequest(BaseModel): # The session ID for this request. sessionId: Annotated[str, Field(description="The session ID for this request.")] # Details about the tool call requiring permission. - toolCall: Annotated[ - ToolCallUpdate, - Field(description="Details about the tool call requiring permission."), - ] + toolCall: Annotated[ToolCall, Field(description="Details about the tool call requiring permission.")] class ToolCallStart(BaseModel): @@ -1299,47 +1315,76 @@ class ToolCallProgress(BaseModel): toolCallId: Annotated[str, Field(description="The ID of the tool call being updated.")] -class ToolCall(BaseModel): - # Extension point for implementations - field_meta: Annotated[ - Optional[Any], - Field(alias="_meta", description="Extension point for implementations"), - ] = None - # Content produced by the tool call. - content: Annotated[ - Optional[List[Union[ContentToolCallContent, FileEditToolCallContent, TerminalToolCallContent]]], - Field(description="Content produced by the tool call."), +class AgentResponseMessage(BaseModel): + jsonrpc: Jsonrpc + # JSON RPC Request Id + # + # An identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2] + # + # The Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects. + # + # [1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling. + # + # [2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions. + id: Annotated[ + Optional[Union[int, str]], + Field( + description="JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions." + ), ] = None - # The category of tool being invoked. - # Helps clients choose appropriate icons and UI treatment. - kind: Annotated[ - Optional[ToolKind], + # All possible responses that an agent can send to a client. + # + # This enum is used internally for routing RPC responses. You typically won't need + # to use this directly - the responses are handled automatically by the connection. + # + # These are responses to the corresponding `ClientRequest` variants. + result: Annotated[ + Union[ + InitializeResponse, + AuthenticateResponse, + NewSessionResponse, + LoadSessionResponse, + SetSessionModeResponse, + PromptResponse, + SetSessionModelResponse, + Any, + ], Field( - description="The category of tool being invoked.\nHelps clients choose appropriate icons and UI treatment." + description="All possible responses that an agent can send to a client.\n\nThis enum is used internally for routing RPC responses. You typically won't need\nto use this directly - the responses are handled automatically by the connection.\n\nThese are responses to the corresponding `ClientRequest` variants." + ), + ] + + +class ClientRequestMessage(BaseModel): + jsonrpc: Jsonrpc + # JSON RPC Request Id + # + # An identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2] + # + # The Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects. + # + # [1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling. + # + # [2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions. + id: Annotated[ + Optional[Union[int, str]], + Field( + description="JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions." ), ] = None - # File locations affected by this tool call. - # Enables "follow-along" features in clients. - locations: Annotated[ - Optional[List[ToolCallLocation]], - Field(description='File locations affected by this tool call.\nEnables "follow-along" features in clients.'), + method: str + params: Optional[ + Union[ + InitializeRequest, + AuthenticateRequest, + NewSessionRequest, + LoadSessionRequest, + SetSessionModeRequest, + PromptRequest, + SetSessionModelRequest, + Any, + ] ] = None - # Raw input parameters sent to the tool. - rawInput: Annotated[Optional[Any], Field(description="Raw input parameters sent to the tool.")] = None - # Raw output returned by the tool. - rawOutput: Annotated[Optional[Any], Field(description="Raw output returned by the tool.")] = None - # Current execution status of the tool call. - status: Annotated[Optional[ToolCallStatus], Field(description="Current execution status of the tool call.")] = None - # Human-readable title describing what the tool is doing. - title: Annotated[ - str, - Field(description="Human-readable title describing what the tool is doing."), - ] - # Unique identifier for this tool call within the session. - toolCallId: Annotated[ - str, - Field(description="Unique identifier for this tool call within the session."), - ] class SessionNotification(BaseModel): @@ -1366,57 +1411,25 @@ class SessionNotification(BaseModel): ] -class Model( - RootModel[ - Union[ - Union[ - WriteTextFileRequest, - ReadTextFileRequest, - RequestPermissionRequest, - CreateTerminalRequest, - TerminalOutputRequest, - ReleaseTerminalRequest, - WaitForTerminalExitRequest, - KillTerminalCommandRequest, - Any, - ], - Union[ - WriteTextFileResponse, - ReadTextFileResponse, - RequestPermissionResponse, - CreateTerminalResponse, - TerminalOutputResponse, - ReleaseTerminalResponse, - WaitForTerminalExitResponse, - KillTerminalCommandResponse, - Any, - ], - Union[CancelNotification, Any], - Union[ - InitializeRequest, - AuthenticateRequest, - NewSessionRequest, - LoadSessionRequest, - SetSessionModeRequest, - PromptRequest, - SetSessionModelRequest, - Any, - ], - Union[ - InitializeResponse, - AuthenticateResponse, - NewSessionResponse, - LoadSessionResponse, - SetSessionModeResponse, - PromptResponse, - SetSessionModelResponse, - Any, - ], - Union[SessionNotification, Any], - ] - ] -): - root: Union[ +class AgentRequestMessage(BaseModel): + jsonrpc: Jsonrpc + # JSON RPC Request Id + # + # An identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2] + # + # The Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects. + # + # [1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling. + # + # [2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions. + id: Annotated[ + Optional[Union[int, str]], + Field( + description="JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null [1] and Numbers SHOULD NOT contain fractional parts [2]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n[1] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n[2] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions." + ), + ] = None + method: str + params: Optional[ Union[ WriteTextFileRequest, ReadTextFileRequest, @@ -1427,45 +1440,56 @@ class Model( WaitForTerminalExitRequest, KillTerminalCommandRequest, Any, - ], + ] + ] = None + + +class AgentNotificationMessage(BaseModel): + jsonrpc: Jsonrpc + method: str + params: Optional[Union[SessionNotification, Any]] = None + + +class Model( + RootModel[ Union[ - WriteTextFileResponse, - ReadTextFileResponse, - RequestPermissionResponse, - CreateTerminalResponse, - TerminalOutputResponse, - ReleaseTerminalResponse, - WaitForTerminalExitResponse, - KillTerminalCommandResponse, - Any, - ], - Union[CancelNotification, Any], + Union[ + AgentRequestMessage, + Union[AgentResponseMessage, AgentErrorMessage], + AgentNotificationMessage, + ], + Union[ + ClientRequestMessage, + Union[ClientResponseMessage, ClientErrorMessage], + ClientNotificationMessage, + ], + ] + ] +): + root: Union[ Union[ - InitializeRequest, - AuthenticateRequest, - NewSessionRequest, - LoadSessionRequest, - SetSessionModeRequest, - PromptRequest, - SetSessionModelRequest, - Any, + AgentRequestMessage, + Union[AgentResponseMessage, AgentErrorMessage], + AgentNotificationMessage, ], Union[ - InitializeResponse, - AuthenticateResponse, - NewSessionResponse, - LoadSessionResponse, - SetSessionModeResponse, - PromptResponse, - SetSessionModelResponse, - Any, + ClientRequestMessage, + Union[ClientResponseMessage, ClientErrorMessage], + ClientNotificationMessage, ], - Union[SessionNotification, Any], ] # Backwards compatibility aliases +AgentOutgoingMessage1 = AgentRequestMessage +AgentOutgoingMessage2 = AgentResponseMessage +AgentOutgoingMessage3 = AgentErrorMessage +AgentOutgoingMessage4 = AgentNotificationMessage AvailableCommandInput1 = CommandInputHint +ClientOutgoingMessage1 = ClientRequestMessage +ClientOutgoingMessage2 = ClientResponseMessage +ClientOutgoingMessage3 = ClientErrorMessage +ClientOutgoingMessage4 = ClientNotificationMessage ContentBlock1 = TextContentBlock ContentBlock2 = ImageContentBlock ContentBlock3 = AudioContentBlock diff --git a/tests/contrib/test_contrib_permissions.py b/tests/contrib/test_contrib_permissions.py new file mode 100644 index 0000000..6ed32b7 --- /dev/null +++ b/tests/contrib/test_contrib_permissions.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import pytest + +from acp.contrib.permissions import PermissionBroker, default_permission_options +from acp.contrib.tool_calls import ToolCallTracker +from acp.schema import ( + AllowedOutcome, + ContentToolCallContent, + PermissionOption, + RequestPermissionRequest, + RequestPermissionResponse, + TextContentBlock, +) + + +@pytest.mark.asyncio +async def test_permission_broker_uses_tracker_state(): + captured: dict[str, RequestPermissionRequest] = {} + + async def fake_requester(request: RequestPermissionRequest): + captured["request"] = request + return RequestPermissionResponse( + outcome=AllowedOutcome(optionId=request.options[0].optionId, outcome="selected") + ) + + tracker = ToolCallTracker(id_factory=lambda: "perm-id") + tracker.start("external", title="Need approval") + broker = PermissionBroker("session", fake_requester, tracker=tracker) + + result = await broker.request_for("external", description="Perform sensitive action") + assert isinstance(result.outcome, AllowedOutcome) + assert result.outcome.optionId == captured["request"].options[0].optionId + assert captured["request"].toolCall.content is not None + last_content = captured["request"].toolCall.content[-1] + assert isinstance(last_content, ContentToolCallContent) + assert isinstance(last_content.content, TextContentBlock) + assert last_content.content.text.startswith("Perform sensitive action") + + +@pytest.mark.asyncio +async def test_permission_broker_accepts_custom_options(): + tracker = ToolCallTracker(id_factory=lambda: "custom") + tracker.start("external", title="Custom options") + options = [ + PermissionOption(optionId="allow", name="Allow once", kind="allow_once"), + ] + recorded: list[str] = [] + + async def requester(request: RequestPermissionRequest): + recorded.append(request.options[0].optionId) + return RequestPermissionResponse( + outcome=AllowedOutcome(optionId=request.options[0].optionId, outcome="selected") + ) + + broker = PermissionBroker("session", requester, tracker=tracker) + await broker.request_for("external", options=options) + assert recorded == ["allow"] + + +def test_default_permission_options_shape(): + options = default_permission_options() + assert len(options) == 3 + assert {opt.optionId for opt in options} == {"approve", "approve_for_session", "reject"} diff --git a/tests/contrib/test_contrib_session_state.py b/tests/contrib/test_contrib_session_state.py new file mode 100644 index 0000000..c2339f6 --- /dev/null +++ b/tests/contrib/test_contrib_session_state.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import pytest + +from acp.contrib.session_state import SessionAccumulator +from acp.schema import ( + AgentMessageChunk, + AgentPlanUpdate, + AvailableCommandsUpdate, + ContentToolCallContent, + CurrentModeUpdate, + PlanEntry, + SessionNotification, + TextContentBlock, + ToolCallProgress, + ToolCallStart, + UserMessageChunk, +) + + +def notification(session_id: str, update): + return SessionNotification(sessionId=session_id, update=update) + + +def test_session_accumulator_merges_tool_calls(): + acc = SessionAccumulator() + start = ToolCallStart( + sessionUpdate="tool_call", + toolCallId="call-1", + title="Read file", + status="in_progress", + ) + acc.apply(notification("s", start)) + progress = ToolCallProgress( + sessionUpdate="tool_call_update", + toolCallId="call-1", + status="completed", + content=[ + ContentToolCallContent( + type="content", + content=TextContentBlock(type="text", text="Done"), + ) + ], + ) + snapshot = acc.apply(notification("s", progress)) + tool = snapshot.tool_calls["call-1"] + assert tool.status == "completed" + assert tool.title == "Read file" + assert tool.content and tool.content[0].content.text == "Done" + + +def test_session_accumulator_records_plan_and_mode(): + acc = SessionAccumulator() + acc.apply( + notification( + "s", + AgentPlanUpdate( + sessionUpdate="plan", + entries=[ + PlanEntry(content="Step 1", priority="medium", status="pending"), + ], + ), + ) + ) + snapshot = acc.apply( + notification("s", CurrentModeUpdate(sessionUpdate="current_mode_update", currentModeId="coding")) + ) + assert snapshot.plan_entries[0].content == "Step 1" + assert snapshot.current_mode_id == "coding" + + +def test_session_accumulator_tracks_messages_and_commands(): + acc = SessionAccumulator() + acc.apply( + notification( + "s", + AvailableCommandsUpdate( + sessionUpdate="available_commands_update", + availableCommands=[], + ), + ) + ) + acc.apply( + notification( + "s", + UserMessageChunk( + sessionUpdate="user_message_chunk", + content=TextContentBlock(type="text", text="Hello"), + ), + ) + ) + acc.apply( + notification( + "s", + AgentMessageChunk( + sessionUpdate="agent_message_chunk", + content=TextContentBlock(type="text", text="Hi!"), + ), + ) + ) + snapshot = acc.snapshot() + user_content = snapshot.user_messages[0].content + agent_content = snapshot.agent_messages[0].content + assert isinstance(user_content, TextContentBlock) + assert isinstance(agent_content, TextContentBlock) + assert user_content.text == "Hello" + assert agent_content.text == "Hi!" + + +def test_session_accumulator_auto_resets_on_new_session(): + acc = SessionAccumulator() + acc.apply( + notification( + "s1", + ToolCallStart( + sessionUpdate="tool_call", + toolCallId="call-1", + title="First", + ), + ) + ) + acc.apply( + notification( + "s2", + ToolCallStart( + sessionUpdate="tool_call", + toolCallId="call-2", + title="Second", + ), + ) + ) + + snapshot = acc.snapshot() + assert snapshot.session_id == "s2" + assert "call-1" not in snapshot.tool_calls + assert "call-2" in snapshot.tool_calls + + +def test_session_accumulator_rejects_cross_session_when_auto_reset_disabled(): + acc = SessionAccumulator(auto_reset_on_session_change=False) + acc.apply( + notification( + "s1", + ToolCallStart( + sessionUpdate="tool_call", + toolCallId="call-1", + title="First", + ), + ) + ) + with pytest.raises(ValueError): + acc.apply( + notification( + "s2", + ToolCallStart( + sessionUpdate="tool_call", + toolCallId="call-2", + title="Second", + ), + ) + ) diff --git a/tests/contrib/test_contrib_tool_calls.py b/tests/contrib/test_contrib_tool_calls.py new file mode 100644 index 0000000..a3fb290 --- /dev/null +++ b/tests/contrib/test_contrib_tool_calls.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from acp.contrib.tool_calls import ToolCallTracker +from acp.schema import ContentToolCallContent, TextContentBlock, ToolCallProgress + + +def test_tool_call_tracker_generates_ids_and_updates(): + tracker = ToolCallTracker(id_factory=lambda: "generated-id") + start = tracker.start("external", title="Run command") + assert start.toolCallId == "generated-id" + progress = tracker.progress("external", status="completed") + assert isinstance(progress, ToolCallProgress) + assert progress.toolCallId == "generated-id" + view = tracker.view("external") + assert view.status == "completed" + + +def test_tool_call_tracker_streaming_text_updates_content(): + tracker = ToolCallTracker(id_factory=lambda: "stream-id") + tracker.start("external", title="Stream", status="in_progress") + update1 = tracker.append_stream_text("external", "hello") + assert update1.content is not None + first_content = update1.content[0] + assert isinstance(first_content, ContentToolCallContent) + assert isinstance(first_content.content, TextContentBlock) + assert first_content.content.text == "hello" + update2 = tracker.append_stream_text("external", ", world", status="in_progress") + assert update2.content is not None + second_content = update2.content[0] + assert isinstance(second_content, ContentToolCallContent) + assert isinstance(second_content.content, TextContentBlock) + assert second_content.content.text == "hello, world" diff --git a/tests/real_user/test_permission_flow.py b/tests/real_user/test_permission_flow.py index f337cce..b07817c 100644 --- a/tests/real_user/test_permission_flow.py +++ b/tests/real_user/test_permission_flow.py @@ -3,7 +3,7 @@ import pytest from acp import AgentSideConnection, ClientSideConnection, PromptRequest, PromptResponse, RequestPermissionRequest -from acp.schema import PermissionOption, TextContentBlock, ToolCallUpdate +from acp.schema import PermissionOption, TextContentBlock, ToolCall from tests.test_rpc import TestAgent, TestClient, _Server # Regression from real-world runs where agents paused prompts to obtain user permission. @@ -25,7 +25,7 @@ async def prompt(self, params: PromptRequest) -> PromptResponse: PermissionOption(optionId="allow", name="Allow", kind="allow_once"), PermissionOption(optionId="deny", name="Deny", kind="reject_once"), ], - toolCall=ToolCallUpdate(toolCallId="call-1", title="Write File"), + toolCall=ToolCall(toolCallId="call-1", title="Write File"), ) ) self.permission_responses.append(permission) diff --git a/tests/test_rpc.py b/tests/test_rpc.py index eba7321..5373045 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -46,10 +46,10 @@ DeniedOutcome, PermissionOption, TextContentBlock, + ToolCall, ToolCallLocation, ToolCallProgress, ToolCallStart, - ToolCallUpdate, UserMessageChunk, ) @@ -470,7 +470,7 @@ async def prompt(self, params: PromptRequest) -> PromptResponse: permission_request = RequestPermissionRequest( sessionId=params.sessionId, - toolCall=ToolCallUpdate( + toolCall=ToolCall( toolCallId="call_1", title="Modifying configuration", kind="edit", diff --git a/uv.lock b/uv.lock index 8f903ae..388bd41 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10, <4.0" [[package]] name = "agent-client-protocol" -version = "0.4.9" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "pydantic" },