From 6aa2167f94111048e11bf0cb846a3f7565063225 Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Wed, 13 May 2026 12:20:25 +0800 Subject: [PATCH 01/10] feat(acp): add elicitation schema types Wave 1 complete: Add ElicitationCreateRequest, ElicitationCreateResponse, ElicitationCompleteNotification, URLElicitationRequiredError, and ElicitationCapabilities types to ACP schema layer. - elicitation.py: 4 core types with camelCase aliases - capabilities.py: ElicitationCapabilities on ClientCapabilities - agent_requests.py: ElicitationCreateRequest in AgentRequest union - client_responses.py: ElicitationCreateResponse in ClientResponse union - notifications.py: ElicitationCompleteNotification in ClientNotification union - messages.py: elicitation/create in ClientMethod Literal - __init__.py: All types exported --- src/acp/schema/__init__.py | 12 ++++ src/acp/schema/agent_requests.py | 4 ++ src/acp/schema/capabilities.py | 23 ++++++- src/acp/schema/client_responses.py | 2 + src/acp/schema/elicitation.py | 99 ++++++++++++++++++++++++++++++ src/acp/schema/messages.py | 4 +- src/acp/schema/notifications.py | 3 +- 7 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 src/acp/schema/elicitation.py diff --git a/src/acp/schema/__init__.py b/src/acp/schema/__init__.py index e420ebb9c..53a5476ce 100644 --- a/src/acp/schema/__init__.py +++ b/src/acp/schema/__init__.py @@ -33,6 +33,7 @@ AgentCapabilities, AuthCapabilities, ClientCapabilities, + ElicitationCapabilities, FileSystemCapability, McpCapabilities, PromptCapabilities, @@ -90,6 +91,12 @@ TextContentBlock, TextResourceContents, ) +from acp.schema.elicitation import ( # noqa: TC001 + ElicitationCompleteNotification, + ElicitationCreateRequest, + ElicitationCreateResponse, + URLElicitationRequiredError, +) from acp.schema.mcp import ( HttpHeader, HttpMcpServer, @@ -202,6 +209,10 @@ "CustomRequest", "CustomResponse", "DeniedOutcome", + "ElicitationCapabilities", + "ElicitationCompleteNotification", + "ElicitationCreateRequest", + "ElicitationCreateResponse", "EmbeddedResourceContentBlock", "EnvVariable", "ExtNotification", @@ -288,6 +299,7 @@ "Usage", "UsageUpdate", "UserMessageChunk", + "URLElicitationRequiredError", "WaitForTerminalExitRequest", "WaitForTerminalExitResponse", "WriteTextFileRequest", diff --git a/src/acp/schema/agent_requests.py b/src/acp/schema/agent_requests.py index 312cf2f60..b92155976 100644 --- a/src/acp/schema/agent_requests.py +++ b/src/acp/schema/agent_requests.py @@ -114,10 +114,14 @@ class RequestPermissionRequest(BaseAgentRequest): """Details about the tool call requiring permission.""" +from acp.schema.elicitation import ElicitationCreateRequest # noqa: TC001 + + AgentRequest = ( WriteTextFileRequest | ReadTextFileRequest | RequestPermissionRequest + | ElicitationCreateRequest | CreateTerminalRequest | TerminalOutputRequest | ReleaseTerminalRequest diff --git a/src/acp/schema/capabilities.py b/src/acp/schema/capabilities.py index 32bc04afe..b4ca2154e 100644 --- a/src/acp/schema/capabilities.py +++ b/src/acp/schema/capabilities.py @@ -34,6 +34,19 @@ class AuthCapabilities(AnnotatedObject): """Whether the client supports ``terminal`` authentication methods.""" +class ElicitationCapabilities(AnnotatedObject): + """Elicitation capabilities supported by the client. + + Advertised during initialization to inform the agent whether + the client supports the `elicitation/create` method. + + See protocol docs: [Elicitation](https://agentclientprotocol.com/protocol/elicitation) + """ + + create: bool | None = False + """Whether the Client supports `elicitation/create` requests.""" + + class ClientCapabilities(AnnotatedObject): """Capabilities supported by the client. @@ -55,6 +68,13 @@ class ClientCapabilities(AnnotatedObject): terminal: bool | None = False """Whether the Client support all `terminal/*` methods.""" + elicitation: ElicitationCapabilities | None = None + """Elicitation capabilities supported by the client. + + Determines whether the agent can use `elicitation/create` for + structured user input, or must fall back to `request_permission`. + """ + @classmethod def create( cls, @@ -62,6 +82,7 @@ def create( write_text_file: bool | None = False, terminal: bool | None = False, auth: AuthCapabilities | None = None, + elicitation: ElicitationCapabilities | None = None, ) -> Self: """Create a new instance of ClientCapabilities. @@ -75,7 +96,7 @@ def create( A new instance of ClientCapabilities. """ fs = FileSystemCapability(read_text_file=read_text_file, write_text_file=write_text_file) - return cls(fs=fs, terminal=terminal, auth=auth) + return cls(fs=fs, terminal=terminal, auth=auth, elicitation=elicitation) class PromptCapabilities(AnnotatedObject): diff --git a/src/acp/schema/client_responses.py b/src/acp/schema/client_responses.py index 2edc37a33..871b5d87a 100644 --- a/src/acp/schema/client_responses.py +++ b/src/acp/schema/client_responses.py @@ -5,6 +5,7 @@ from typing import Any, Self from acp.schema.base import Response +from acp.schema.elicitation import ElicitationCreateResponse # noqa: TC001 from acp.schema.terminal import TerminalExitStatus # noqa: TC001 from acp.schema.tool_call import AllowedOutcome, DeniedOutcome @@ -93,4 +94,5 @@ def allowed(cls, option_id: str, metadata: dict[str, Any] | None = None) -> Self | ReleaseTerminalResponse | WaitForTerminalExitResponse | KillTerminalCommandResponse + | ElicitationCreateResponse ) diff --git a/src/acp/schema/elicitation.py b/src/acp/schema/elicitation.py new file mode 100644 index 000000000..8bcc77c99 --- /dev/null +++ b/src/acp/schema/elicitation.py @@ -0,0 +1,99 @@ +"""Elicitation schema definitions for ACP.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field + +from acp.schema.base import AnnotatedObject, Request, Response, Schema + + +class ElicitationCreateRequest(Request): + """Request to elicit input from the user. + + Sent when the agent needs structured input from the user that goes + beyond simple permission grants. Supports both form-based and URL-based + elicitation patterns. + + See protocol docs: [Elicitation](https://agentclientprotocol.com/protocol/elicitation) + """ + + session_id: str + """The session ID for this request.""" + + message: str + """A human-readable message describing what input is being requested.""" + + requested_schema: dict[str, Any] = Field(alias="requestedSchema") + """A JSON Schema object describing the expected input structure.""" + + url: str | None = None + """Optional URL for URL-based elicitation (e.g., OAuth flows). + + When present, the client should open this URL for the user to complete + the elicitation externally, then signal completion via notification. + """ + + +class ElicitationCreateResponse(Response): + """Response to an elicitation request. + + Contains the user's decision and optionally the structured content + they provided. + + See protocol docs: [Elicitation](https://agentclientprotocol.com/protocol/elicitation) + """ + + action: Literal["accept", "decline", "cancel"] + """The user's decision on the elicitation request. + + - accept: User provided the requested input + - decline: User declined to provide input + - cancel: User cancelled the elicitation + """ + + content: dict[str, Any] | None = None + """The structured content provided by the user. + + Only present when action is 'accept'. Must conform to the + requested_schema from the original request. + """ + + +class ElicitationCompleteNotification(AnnotatedObject): + """Notification signaling completion of a URL-based elicitation. + + Sent by the client when the user has completed an external elicitation + flow (e.g., finished OAuth in the browser). This is a fire-and-forget + notification - the agent does not wait for or expect a response. + + See protocol docs: [Elicitation](https://agentclientprotocol.com/protocol/elicitation) + """ + + session_id: str + """The session ID this elicitation belongs to.""" + + action: Literal["accept", "decline", "cancel"] + """The user's decision after completing the external elicitation.""" + + content: dict[str, Any] | None = None + """The structured content resulting from the elicitation. + + Only present when action is 'accept'. + """ + + +class URLElicitationRequiredError(Schema): + """Error indicating that URL-based elicitation is required. + + Returned when the agent requests elicitation but the client does not + support the `elicitation/create` method. The client can use the + provided URL to complete the elicitation externally. + """ + + url: str + """The URL the user should visit to complete the elicitation.""" + + message: str + """A human-readable message explaining what the user needs to do.""" diff --git a/src/acp/schema/messages.py b/src/acp/schema/messages.py index 532fbfd48..66cfa94c1 100644 --- a/src/acp/schema/messages.py +++ b/src/acp/schema/messages.py @@ -14,6 +14,7 @@ from acp.schema.common import Error # noqa: TC001 from acp.schema.notifications import ( # noqa: TC001 CancelNotification, + ElicitationCompleteNotification, SessionNotification, ) @@ -34,6 +35,7 @@ ] ClientMethod = Literal[ + "elicitation/create", "fs/read_text_file", "fs/write_text_file", "session/request_permission", @@ -64,7 +66,7 @@ class ClientNotificationMessage(JsonRPCMessage): method: ClientMethod | str """Method name.""" - params: CancelNotification | Any | None = None + params: CancelNotification | ElicitationCompleteNotification | Any | None = None """Agent notification parameters.""" diff --git a/src/acp/schema/notifications.py b/src/acp/schema/notifications.py index 00e27c5fd..112e5cf0a 100644 --- a/src/acp/schema/notifications.py +++ b/src/acp/schema/notifications.py @@ -5,6 +5,7 @@ from typing import Any, Generic, TypeVar from acp.schema.base import AnnotatedObject +from acp.schema.elicitation import ElicitationCompleteNotification # noqa: TC001 from acp.schema.session_updates import SessionUpdate @@ -72,7 +73,7 @@ class ExtNotification(AnnotatedObject): Notifications do not expect a response. """ -ClientNotification = CancelNotification | ExtNotification +ClientNotification = CancelNotification | ElicitationCompleteNotification | ExtNotification """All possible notifications that a client can send to an agent. This is used internally for routing RPC notifications. From 3c2d2e15af7f7b7bd1a4a7626c6db0d69775f17a Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Wed, 13 May 2026 14:25:18 +0800 Subject: [PATCH 02/10] fix(acp): clean up elicitation schema imports and docstrings Remove unused noqa comments, fix import ordering, add missing docstring arg. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/acp/schema/__init__.py | 4 ++-- src/acp/schema/agent_requests.py | 4 +--- src/acp/schema/capabilities.py | 3 ++- src/acp/schema/client_responses.py | 2 +- src/acp/schema/notifications.py | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/acp/schema/__init__.py b/src/acp/schema/__init__.py index 53a5476ce..ddec5262c 100644 --- a/src/acp/schema/__init__.py +++ b/src/acp/schema/__init__.py @@ -91,7 +91,7 @@ TextContentBlock, TextResourceContents, ) -from acp.schema.elicitation import ( # noqa: TC001 +from acp.schema.elicitation import ( ElicitationCompleteNotification, ElicitationCreateRequest, ElicitationCreateResponse, @@ -296,10 +296,10 @@ "ToolCallProgress", "ToolCallStart", "ToolCallStatus", + "URLElicitationRequiredError", "Usage", "UsageUpdate", "UserMessageChunk", - "URLElicitationRequiredError", "WaitForTerminalExitRequest", "WaitForTerminalExitResponse", "WriteTextFileRequest", diff --git a/src/acp/schema/agent_requests.py b/src/acp/schema/agent_requests.py index b92155976..4d935a16e 100644 --- a/src/acp/schema/agent_requests.py +++ b/src/acp/schema/agent_requests.py @@ -6,6 +6,7 @@ from acp.schema.base import Request from acp.schema.common import EnvVariable # noqa: TC001 +from acp.schema.elicitation import ElicitationCreateRequest from acp.schema.tool_call import PermissionOption, ToolCall # noqa: TC001 @@ -114,9 +115,6 @@ class RequestPermissionRequest(BaseAgentRequest): """Details about the tool call requiring permission.""" -from acp.schema.elicitation import ElicitationCreateRequest # noqa: TC001 - - AgentRequest = ( WriteTextFileRequest | ReadTextFileRequest diff --git a/src/acp/schema/capabilities.py b/src/acp/schema/capabilities.py index b4ca2154e..d0a5918c0 100644 --- a/src/acp/schema/capabilities.py +++ b/src/acp/schema/capabilities.py @@ -7,7 +7,7 @@ from pydantic import Field from acp.schema.base import AnnotatedObject -from acp.schema.slash_commands import AvailableCommand +from acp.schema.slash_commands import AvailableCommand # noqa: TC001 class FileSystemCapability(AnnotatedObject): @@ -91,6 +91,7 @@ def create( write_text_file: Whether the Client supports `fs/write_text_file` requests. terminal: Whether the Client supports all `terminal/*` methods. auth: Authentication capabilities supported by the client. + elicitation: Elicitation capabilities supported by the client. Returns: A new instance of ClientCapabilities. diff --git a/src/acp/schema/client_responses.py b/src/acp/schema/client_responses.py index 871b5d87a..2a7243fd4 100644 --- a/src/acp/schema/client_responses.py +++ b/src/acp/schema/client_responses.py @@ -5,7 +5,7 @@ from typing import Any, Self from acp.schema.base import Response -from acp.schema.elicitation import ElicitationCreateResponse # noqa: TC001 +from acp.schema.elicitation import ElicitationCreateResponse from acp.schema.terminal import TerminalExitStatus # noqa: TC001 from acp.schema.tool_call import AllowedOutcome, DeniedOutcome diff --git a/src/acp/schema/notifications.py b/src/acp/schema/notifications.py index 112e5cf0a..3564dac93 100644 --- a/src/acp/schema/notifications.py +++ b/src/acp/schema/notifications.py @@ -5,7 +5,7 @@ from typing import Any, Generic, TypeVar from acp.schema.base import AnnotatedObject -from acp.schema.elicitation import ElicitationCompleteNotification # noqa: TC001 +from acp.schema.elicitation import ElicitationCompleteNotification from acp.schema.session_updates import SessionUpdate From e6b39a9f246c0fd2d29fece2e5689c4ecacba9eb Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Wed, 13 May 2026 14:25:23 +0800 Subject: [PATCH 03/10] feat(acp): add elicitation protocol methods and routing Add elicitation_create() to Client protocol, ACPRequests, both connection handlers. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/acp/agent/acp_requests.py | 27 +++++++++++++++++++++++++++ src/acp/agent/connection.py | 11 +++++++++++ src/acp/client/connection.py | 6 ++++++ src/acp/client/protocol.py | 6 ++++++ 4 files changed, 50 insertions(+) diff --git a/src/acp/agent/acp_requests.py b/src/acp/agent/acp_requests.py index dcead4613..039f3eb1f 100644 --- a/src/acp/agent/acp_requests.py +++ b/src/acp/agent/acp_requests.py @@ -9,6 +9,7 @@ from acp.schema import ( CreateTerminalRequest, + ElicitationCreateRequest, EnvVariable, KillTerminalCommandRequest, PermissionOption, @@ -32,6 +33,7 @@ TerminalOutputResponse, WaitForTerminalExitResponse, ) + from acp.schema import ElicitationCreateResponse logger = structlog.get_logger(__name__) @@ -207,3 +209,28 @@ async def request_permission( tool_call = ToolCall(tool_call_id=tool_call_id, title=title, raw_input=raw_input) request = RequestPermissionRequest(session_id=self.id, tool_call=tool_call, options=options) return await self.client.request_permission(request) + + async def elicitation_create( + self, + message: str, + *, + requested_schema: dict[str, Any], + url: str | None = None, + ) -> ElicitationCreateResponse: + """Elicit structured input from the user. + + Args: + message: Human-readable message describing what input is being requested + requested_schema: JSON Schema object describing the expected input structure + url: Optional URL for URL-based elicitation (e.g., OAuth flows) + + Returns: + Elicitation response with user's decision and optional content + """ + request = ElicitationCreateRequest( + session_id=self.id, + message=message, + requested_schema=requested_schema, + url=url, + ) + return await self.client.elicitation_create(request) diff --git a/src/acp/agent/connection.py b/src/acp/agent/connection.py index 93da9c59c..012fb5faa 100644 --- a/src/acp/agent/connection.py +++ b/src/acp/agent/connection.py @@ -17,6 +17,7 @@ CancelNotification, CreateTerminalRequest, CreateTerminalResponse, + ElicitationCreateResponse, InitializeRequest, KillTerminalCommandRequest, KillTerminalCommandResponse, @@ -55,6 +56,7 @@ from acp.schema import ( AgentMethod, CreateTerminalRequest, + ElicitationCreateRequest, InitializeResponse, KillTerminalCommandRequest, ListSessionsResponse, @@ -139,6 +141,15 @@ async def request_permission( resp = await self._conn.send_request(method, dct) return RequestPermissionResponse.model_validate(resp) + async def elicitation_create( + self, params: ElicitationCreateRequest + ) -> ElicitationCreateResponse: + """Elicit input from the client.""" + dct = params.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True) + method = "elicitation/create" + resp = await self._conn.send_request(method, dct) + return ElicitationCreateResponse.model_validate(resp) + async def read_text_file(self, params: ReadTextFileRequest) -> ReadTextFileResponse: """Read text file from the client.""" dct = params.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True) diff --git a/src/acp/client/connection.py b/src/acp/client/connection.py index fd959a793..eed806c97 100644 --- a/src/acp/client/connection.py +++ b/src/acp/client/connection.py @@ -13,6 +13,7 @@ from acp.schema import ( AuthenticateResponse, CreateTerminalRequest, + ElicitationCreateRequest, ForkSessionResponse, InitializeResponse, KillTerminalCommandRequest, @@ -47,6 +48,7 @@ CancelNotification, ClientMethod, CreateTerminalResponse, + ElicitationCreateResponse, ForkSessionRequest, InitializeRequest, KillTerminalCommandResponse, @@ -216,6 +218,7 @@ async def _handle_client_method( # noqa: PLR0911 WriteTextFileResponse | ReadTextFileResponse | RequestPermissionResponse + | ElicitationCreateResponse | SessionNotification | CreateTerminalResponse | TerminalOutputResponse @@ -236,6 +239,9 @@ async def _handle_client_method( # noqa: PLR0911 case "session/request_permission": permission_request = RequestPermissionRequest.model_validate(params) return await client.request_permission(permission_request) + case "elicitation/create": + elicitation_request = ElicitationCreateRequest.model_validate(params) + return await client.elicitation_create(elicitation_request) case "session/update": notification = SessionNotification.model_validate(params) await client.session_update(notification) diff --git a/src/acp/client/protocol.py b/src/acp/client/protocol.py index f366cbebb..0f27fd810 100644 --- a/src/acp/client/protocol.py +++ b/src/acp/client/protocol.py @@ -7,6 +7,8 @@ from acp.schema import ( CreateTerminalRequest, CreateTerminalResponse, + ElicitationCreateRequest, + ElicitationCreateResponse, KillTerminalCommandRequest, KillTerminalCommandResponse, ReadTextFileRequest, @@ -32,6 +34,10 @@ async def request_permission( self, params: RequestPermissionRequest ) -> RequestPermissionResponse: ... + async def elicitation_create( + self, params: ElicitationCreateRequest + ) -> ElicitationCreateResponse: ... + async def session_update(self, params: SessionNotification) -> None: ... async def write_text_file( From d245cd6bcf3d64dd8c17ec7975371bb8459f9888 Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Wed, 13 May 2026 14:25:29 +0800 Subject: [PATCH 04/10] feat(acp): implement elicitation_create in all client implementations DefaultACPClient auto-accepts with tracking, HeadlessACPClient gates on auto_grant, NoOpClient cancels. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../client/implementations/default_client.py | 17 ++++++++++++++++- .../client/implementations/headless_client.py | 15 +++++++++++++++ src/acp/client/implementations/noop_client.py | 10 ++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/acp/client/implementations/default_client.py b/src/acp/client/implementations/default_client.py index ed6a4ceaa..163aa2c16 100644 --- a/src/acp/client/implementations/default_client.py +++ b/src/acp/client/implementations/default_client.py @@ -12,7 +12,12 @@ import structlog from acp.client import Client -from acp.schema import ReadTextFileResponse, RequestPermissionResponse, WriteTextFileResponse +from acp.schema import ( + ElicitationCreateResponse, + ReadTextFileResponse, + RequestPermissionResponse, + WriteTextFileResponse, +) if TYPE_CHECKING: @@ -21,6 +26,7 @@ CreateTerminalRequest, CreateTerminalResponse, DeniedOutcome, + ElicitationCreateRequest, KillTerminalCommandRequest, KillTerminalCommandResponse, ReadTextFileRequest, @@ -66,6 +72,7 @@ def __init__( self.ext_calls: list[tuple[str, dict[str, Any]]] = [] self.ext_notes: list[tuple[str, dict[str, Any]]] = [] self.notifications: list[SessionNotification] = [] + self.elicitation_calls: list[ElicitationCreateRequest] = [] async def request_permission( self, params: RequestPermissionRequest @@ -87,6 +94,14 @@ async def request_permission( # No options - deny return RequestPermissionResponse.denied() + async def elicitation_create( + self, params: ElicitationCreateRequest + ) -> ElicitationCreateResponse: + """Default elicitation handler - logs and auto-accepts.""" + logger.info("Elicitation requested", message=params.message) + self.elicitation_calls.append(params) + return ElicitationCreateResponse(action="accept", content={}) + async def session_update(self, params: SessionNotification) -> None: """Handle session update notifications.""" msg = "Session update for %s: %s" diff --git a/src/acp/client/implementations/headless_client.py b/src/acp/client/implementations/headless_client.py index 6863f5610..1a57d215c 100644 --- a/src/acp/client/implementations/headless_client.py +++ b/src/acp/client/implementations/headless_client.py @@ -17,6 +17,7 @@ from acp.client.protocol import Client from acp.schema import ( CreateTerminalResponse, + ElicitationCreateResponse, KillTerminalCommandResponse, ReadTextFileResponse, ReleaseTerminalResponse, @@ -30,6 +31,7 @@ if TYPE_CHECKING: from acp.schema import ( CreateTerminalRequest, + ElicitationCreateRequest, KillTerminalCommandRequest, ReadTextFileRequest, ReleaseTerminalRequest, @@ -76,6 +78,7 @@ def __init__( # Tracking for testing/debugging self.notifications: list[SessionNotification] = [] self.permission_requests: list[RequestPermissionRequest] = [] + self.elicitation_requests: list[ElicitationCreateRequest] = [] async def request_permission( self, params: RequestPermissionRequest @@ -92,6 +95,18 @@ async def request_permission( logger.debug("Denying permission", tool_name=tool_name) return RequestPermissionResponse.denied() + async def elicitation_create( + self, params: ElicitationCreateRequest + ) -> ElicitationCreateResponse: + """Handle elicitation requests. Accepts if auto_grant_permissions is True.""" + self.elicitation_requests.append(params) + logger.info("Elicitation requested", message=params.message) + if self.auto_grant_permissions: + logger.debug("Auto-accepting elicitation", message=params.message) + return ElicitationCreateResponse(action="accept", content={}) + logger.debug("Declining elicitation", message=params.message) + return ElicitationCreateResponse(action="decline") + async def session_update(self, params: SessionNotification) -> None: """Handle session update notifications.""" typ = type(params.update).__name__ diff --git a/src/acp/client/implementations/noop_client.py b/src/acp/client/implementations/noop_client.py index ba9195b04..83fd64707 100644 --- a/src/acp/client/implementations/noop_client.py +++ b/src/acp/client/implementations/noop_client.py @@ -17,6 +17,8 @@ from acp.schema import ( CreateTerminalRequest, CreateTerminalResponse, + ElicitationCreateRequest, + ElicitationCreateResponse, KillTerminalCommandRequest, KillTerminalCommandResponse, ReadTextFileRequest, @@ -51,6 +53,14 @@ async def request_permission( return RequestPermissionResponse(outcome=AllowedOutcome(option_id="allow")) + async def elicitation_create( + self, params: ElicitationCreateRequest + ) -> ElicitationCreateResponse: + """Decline all elicitation requests.""" + from acp.schema import ElicitationCreateResponse + + return ElicitationCreateResponse(action="cancel") + async def session_update(self, params: SessionNotification) -> None: """Ignore session updates.""" From 587ee513f0b131fb9ef0153f57a31cce233d317c Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Wed, 13 May 2026 14:25:35 +0800 Subject: [PATCH 05/10] feat(acp-server): capability-gated elicitation with permission fallback Dual-path in ACPInputProvider: use elicitation_create when client declares capability, fall back to request_permission for legacy clients. Add send_elicitation_complete to ACPNotifications. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/acp/agent/notifications.py | 29 ++- .../acp_server/input_provider.py | 205 ++++++++++++------ 2 files changed, 161 insertions(+), 73 deletions(-) diff --git a/src/acp/agent/notifications.py b/src/acp/agent/notifications.py index d68d3c95f..f5466688e 100644 --- a/src/acp/agent/notifications.py +++ b/src/acp/agent/notifications.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, assert_never +from typing import TYPE_CHECKING, Any, Literal, assert_never from pydantic_ai import ModelRequest, ModelResponse, ToolReturnPart, UserPromptPart import structlog @@ -626,6 +626,33 @@ async def send_agent_audio( ) await self.send_update(update) + async def send_elicitation_complete( + self, + action: Literal["accept", "decline", "cancel"], + *, + content: dict[str, Any] | None = None, + ) -> None: + """Send an elicitation complete notification. + + Signals that a URL-based elicitation flow has completed. + This is a fire-and-forget notification — the agent does not + wait for a response. + + Args: + action: The user's decision after completing the elicitation + content: The structured content resulting from the elicitation + """ + from acp.schema import ElicitationCompleteNotification + + notification = ElicitationCompleteNotification( + session_id=self.id, + action=action, + content=content, + ) + await self.client.session_update( + SessionNotification(session_id=self.id, update=notification) # type: ignore[arg-type] + ) + async def send_agent_resource( self, name: str, diff --git a/src/agentpool_server/acp_server/input_provider.py b/src/agentpool_server/acp_server/input_provider.py index 0b5b63c4a..ba15a5d84 100644 --- a/src/agentpool_server/acp_server/input_provider.py +++ b/src/agentpool_server/acp_server/input_provider.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from acp import RequestPermissionResponse + from acp.schema.elicitation import ElicitationCreateResponse from agentpool import AgentContext from agentpool.agents.context import ConfirmationResult from agentpool_server.acp_server.session import ACPSession @@ -195,14 +196,35 @@ def _handle_permission_response(self, option_id: str, tool_name: str) -> Confirm logger.warning("Unknown permission option", option_id=option_id) return "abort_run" - async def get_elicitation( # noqa: PLR0911 + def _client_supports_elicitation(self) -> bool: + """Check if the client supports the elicitation/create method.""" + caps = self.session.client_capabilities + return caps.elicitation is not None and bool(caps.elicitation.create) + + @staticmethod + def _map_elicitation_create_response( + response: ElicitationCreateResponse, + ) -> types.ElicitResult: + """Map elicitation/create response action to ElicitResult.""" + match response.action: + case "accept": + return types.ElicitResult(action="accept", content=response.content or {}) + case "decline": + return types.ElicitResult(action="decline") + case "cancel": + return types.ElicitResult(action="cancel") + case _ as unreachable: + assert_never(unreachable) # ty:ignore[type-assertion-failure] + + async def get_elicitation( self, params: types.ElicitRequestParams, ) -> types.ElicitResult | types.ErrorData: - """Get user response to elicitation request with basic schema support. + """Get user response to elicitation request with capability-gated dual path. - Currently supports boolean schemas via ACP permission options. - Other schemas fall back to accept/decline options. + When the client declares the ``elicitation.create`` capability, uses the + native ``elicitation/create`` protocol method. Otherwise falls back to + the legacy ``request_permission`` approach for backward compatibility. Args: params: MCP elicit request parameters @@ -211,80 +233,119 @@ async def get_elicitation( # noqa: PLR0911 Elicit result with user's response or error data """ try: - # Handle URL mode elicitation (OAuth, credentials, payments) if isinstance(params, types.ElicitRequestURLParams): - msg = "URL elicitation request" - elicit_id = params.elicitationId - logger.info(msg, message=params.message, url=params.url, elicitation_id=elicit_id) - tool_call_id = f"elicit_url_{elicit_id}" - title = f"URL Authorization: {params.message}" - url_options = [ - PermissionOption(option_id="accept", name="Open URL", kind="allow_once"), - PermissionOption(option_id="decline", name="Decline", kind="reject_once"), - ] - response = await self.session.requests.request_permission( - tool_call_id=tool_call_id, - title=title, - options=url_options, - ) - match response.outcome: - case AllowedOutcome(option_id="accept"): - webbrowser.open(params.url) - return types.ElicitResult(action="accept") - case AllowedOutcome(): - return types.ElicitResult(action="decline") - case DeniedOutcome(): - return types.ElicitResult(action="cancel") - case _ as unreachable: - assert_never(unreachable) # ty:ignore[type-assertion-failure] - - # Form mode elicitation - schema = params.requestedSchema - logger.info("Elicitation request", message=params.message, schema=schema) - tool_call_id = f"elicit_{hash(params.message)}" - title = f"Elicitation: {params.message}" - - if _is_boolean_schema(schema): - options: list[PermissionOption] | None = _create_boolean_elicitation_options() - response = await self.session.requests.request_permission( - tool_call_id=tool_call_id, - title=title, - options=options, - ) - return self._handle_boolean_elicitation_response(response, schema) - if _is_enum_schema(schema) and (options := _create_enum_elicitation_options(schema)): - response = await self.session.requests.request_permission( - tool_call_id=tool_call_id, - title=title, - options=options, - ) - return _handle_enum_elicitation_response(response, schema) - - options = [ - PermissionOption(option_id="accept", name="Accept", kind="allow_once"), - PermissionOption(option_id="decline", name="Decline", kind="reject_once"), - ] - response = await self.session.requests.request_permission( + return await self._get_url_elicitation(params) + return await self._get_form_elicitation(params) + except Exception as e: + logger.exception("Failed to handle elicitation") + return types.ErrorData(code=types.INTERNAL_ERROR, message=f"Elicitation failed: {e}") + + async def _get_url_elicitation( + self, + params: types.ElicitRequestURLParams, + ) -> types.ElicitResult: + """Handle URL-mode elicitation (OAuth, credentials, payments). + + Uses ``elicitation/create`` when the client supports it, otherwise + falls back to ``request_permission``. + """ + elicit_id = params.elicitationId + logger.info( + "URL elicitation request", + message=params.message, + url=params.url, + elicitation_id=elicit_id, + ) + + if self._client_supports_elicitation(): + response = await self.session.requests.elicitation_create( + message=params.message, + requested_schema={"type": "object"}, + url=params.url, + ) + return self._map_elicitation_create_response(response) + + # Fallback: request_permission + tool_call_id = f"elicit_url_{elicit_id}" + title = f"URL Authorization: {params.message}" + url_options = [ + PermissionOption(option_id="accept", name="Open URL", kind="allow_once"), + PermissionOption(option_id="decline", name="Decline", kind="reject_once"), + ] + perm_response = await self.session.requests.request_permission( + tool_call_id=tool_call_id, + title=title, + options=url_options, + ) + match perm_response.outcome: + case AllowedOutcome(option_id="accept"): + webbrowser.open(params.url) + return types.ElicitResult(action="accept") + case AllowedOutcome(): + return types.ElicitResult(action="decline") + case DeniedOutcome(): + return types.ElicitResult(action="cancel") + case _ as unreachable: + assert_never(unreachable) # ty:ignore[type-assertion-failure] + + async def _get_form_elicitation( + self, + params: types.ElicitRequestFormParams, + ) -> types.ElicitResult | types.ErrorData: + """Handle form-mode elicitation with schema support. + + Uses ``elicitation/create`` when the client supports it, otherwise + falls back to ``request_permission`` with boolean/enum/generic handling. + """ + schema = params.requestedSchema + logger.info("Elicitation request", message=params.message, schema=schema) + + if self._client_supports_elicitation(): + response = await self.session.requests.elicitation_create( + message=params.message, + requested_schema=schema, + ) + return self._map_elicitation_create_response(response) + + # Fallback: request_permission with schema-specific handling + tool_call_id = f"elicit_{hash(params.message)}" + title = f"Elicitation: {params.message}" + + if _is_boolean_schema(schema): + options: list[PermissionOption] | None = _create_boolean_elicitation_options() + perm_response = await self.session.requests.request_permission( tool_call_id=tool_call_id, title=title, options=options, ) + return self._handle_boolean_elicitation_response(perm_response, schema) + if _is_enum_schema(schema) and (options := _create_enum_elicitation_options(schema)): + perm_response = await self.session.requests.request_permission( + tool_call_id=tool_call_id, + title=title, + options=options, + ) + return _handle_enum_elicitation_response(perm_response, schema) + + options = [ + PermissionOption(option_id="accept", name="Accept", kind="allow_once"), + PermissionOption(option_id="decline", name="Decline", kind="reject_once"), + ] + perm_response = await self.session.requests.request_permission( + tool_call_id=tool_call_id, + title=title, + options=options, + ) - # Convert permission response to elicitation result - match response.outcome: - case AllowedOutcome(option_id="accept"): - # For non-boolean schemas, return empty content - return types.ElicitResult(action="accept", content={}) - case AllowedOutcome(): - return types.ElicitResult(action="decline") - case DeniedOutcome(): - return types.ElicitResult(action="cancel") - case _ as unreachable: - assert_never(unreachable) # ty:ignore[type-assertion-failure] - - except Exception as e: - logger.exception("Failed to handle elicitation") - return types.ErrorData(code=types.INTERNAL_ERROR, message=f"Elicitation failed: {e}") + match perm_response.outcome: + case AllowedOutcome(option_id="accept"): + return types.ElicitResult(action="accept", content={}) + case AllowedOutcome(): + return types.ElicitResult(action="decline") + case DeniedOutcome(): + return types.ElicitResult(action="cancel") + case _ as unreachable: + assert_never(unreachable) # ty:ignore[type-assertion-failure] def _handle_boolean_elicitation_response( self, response: RequestPermissionResponse, schema: dict[str, Any] From e7bca1e7fce1baaf3051df319f835f3988ef219c Mon Sep 17 00:00:00 2001 From: Leoyzen Date: Wed, 13 May 2026 14:27:38 +0800 Subject: [PATCH 06/10] chore: add openspec config and claude skill commands Add openspec changes for acp-elicitation and acp-streamable-http-ws-server, plus .claude skills/commands for openspec workflows. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .claude/commands/opsx/apply.md | 152 +++++++++ .claude/commands/opsx/archive.md | 157 ++++++++++ .claude/commands/opsx/explore.md | 173 +++++++++++ .claude/commands/opsx/propose.md | 106 +++++++ .claude/skills/openspec-apply-change/SKILL.md | 156 ++++++++++ .../skills/openspec-archive-change/SKILL.md | 114 +++++++ .claude/skills/openspec-explore/SKILL.md | 288 ++++++++++++++++++ .claude/skills/openspec-propose/SKILL.md | 110 +++++++ .../changes/acp-elicitation/.openspec.yaml | 2 + openspec/changes/acp-elicitation/design.md | 74 +++++ openspec/changes/acp-elicitation/proposal.md | 31 ++ .../specs/acp-elicitation-protocol/spec.md | 58 ++++ .../specs/acp-elicitation-schema/spec.md | 74 +++++ .../specs/acp-elicitation-server/spec.md | 46 +++ openspec/changes/acp-elicitation/tasks.md | 40 +++ .../.openspec.yaml | 2 + .../acp-streamable-http-ws-server/design.md | 79 +++++ .../acp-streamable-http-ws-server/proposal.md | 30 ++ .../specs/ws-server-integration/spec.md | 52 ++++ .../specs/ws-transport/spec.md | 60 ++++ .../acp-streamable-http-ws-server/tasks.md | 48 +++ openspec/config.yaml | 20 ++ 22 files changed, 1872 insertions(+) create mode 100644 .claude/commands/opsx/apply.md create mode 100644 .claude/commands/opsx/archive.md create mode 100644 .claude/commands/opsx/explore.md create mode 100644 .claude/commands/opsx/propose.md create mode 100644 .claude/skills/openspec-apply-change/SKILL.md create mode 100644 .claude/skills/openspec-archive-change/SKILL.md create mode 100644 .claude/skills/openspec-explore/SKILL.md create mode 100644 .claude/skills/openspec-propose/SKILL.md create mode 100644 openspec/changes/acp-elicitation/.openspec.yaml create mode 100644 openspec/changes/acp-elicitation/design.md create mode 100644 openspec/changes/acp-elicitation/proposal.md create mode 100644 openspec/changes/acp-elicitation/specs/acp-elicitation-protocol/spec.md create mode 100644 openspec/changes/acp-elicitation/specs/acp-elicitation-schema/spec.md create mode 100644 openspec/changes/acp-elicitation/specs/acp-elicitation-server/spec.md create mode 100644 openspec/changes/acp-elicitation/tasks.md create mode 100644 openspec/changes/acp-streamable-http-ws-server/.openspec.yaml create mode 100644 openspec/changes/acp-streamable-http-ws-server/design.md create mode 100644 openspec/changes/acp-streamable-http-ws-server/proposal.md create mode 100644 openspec/changes/acp-streamable-http-ws-server/specs/ws-server-integration/spec.md create mode 100644 openspec/changes/acp-streamable-http-ws-server/specs/ws-transport/spec.md create mode 100644 openspec/changes/acp-streamable-http-ws-server/tasks.md create mode 100644 openspec/config.yaml diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md new file mode 100644 index 000000000..ae14f0f5f --- /dev/null +++ b/.claude/commands/opsx/apply.md @@ -0,0 +1,152 @@ +--- +name: "OPSX: Apply" +description: Implement tasks from an OpenSpec change (Experimental) +category: Workflow +tags: [workflow, artifacts, experimental] +--- + +Implement tasks from an OpenSpec change. + +**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue` + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx:archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.