From 3383ef3614971fcd47186ccd3ede0cf67497e9c8 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 11 Oct 2025 02:52:17 +0800 Subject: [PATCH 1/6] refactor: better code style Signed-off-by: Chojan Shang --- src/acp/core.py | 648 +++++++++--------- tests/real_user/__init__.py | 0 .../test_cancel_prompt_flow.py | 10 +- tests/{ => real_user}/test_permission_flow.py | 15 +- 4 files changed, 328 insertions(+), 345 deletions(-) create mode 100644 tests/real_user/__init__.py rename tests/{ => real_user}/test_cancel_prompt_flow.py (90%) rename tests/{ => real_user}/test_permission_flow.py (88%) diff --git a/src/acp/core.py b/src/acp/core.py index 46e13a3..6a67d44 100644 --- a/src/acp/core.py +++ b/src/acp/core.py @@ -46,13 +46,16 @@ WriteTextFileResponse, ) -# --- JSON-RPC 2.0 error helpers ------------------------------------------------- +JsonValue = Any +MethodHandler = Callable[[str, JsonValue | None, bool], Awaitable[JsonValue | None]] _AGENT_CONNECTION_ERROR = "AgentSideConnection requires asyncio StreamWriter/StreamReader" _CLIENT_CONNECTION_ERROR = "ClientSideConnection requires asyncio StreamWriter/StreamReader" class RequestError(Exception): + """JSON-RPC 2.0 error helper.""" + def __init__(self, code: int, message: str, data: Any | None = None) -> None: super().__init__(message) self.code = code @@ -87,15 +90,15 @@ def resource_not_found(uri: str | None = None) -> RequestError: data = {"uri": uri} if uri is not None else None return RequestError(-32002, "Resource not found", data) - def to_error_obj(self) -> dict: + def to_error_obj(self) -> dict[str, Any]: return {"code": self.code, "message": str(self), "data": self.data} -# --- Transport & Connection ------------------------------------------------------ +class _NoMatch: + """Sentinel returned by routing helpers when no handler matches.""" -JsonValue = Any -MethodHandler = Callable[[str, JsonValue | None, bool], Awaitable[JsonValue | None]] -_NO_MATCH = object() + +_NO_MATCH = _NoMatch() @dataclass(slots=True) @@ -103,15 +106,20 @@ class _Pending: future: asyncio.Future[Any] -class Connection: - """ - Minimal JSON-RPC 2.0 connection over newline-delimited JSON frames using - asyncio streams. KISS: only supports StreamReader/StreamWriter. +def _dump_params(params: BaseModel) -> dict[str, Any]: + return params.model_dump(exclude_none=True, exclude_defaults=True) + - - Outgoing messages always include {"jsonrpc": "2.0"} - - Requests and notifications are dispatched to a single async handler - - Responses resolve pending futures by numeric id - """ +def _optional_result(payload: Any) -> dict[str, Any]: + if payload is None: + return {} + if isinstance(payload, BaseModel): + return _dump_params(payload) + return payload + + +class Connection: + """Minimal JSON-RPC 2.0 connection over newline-delimited JSON frames.""" def __init__( self, @@ -129,6 +137,7 @@ def __init__( self._recv_task = asyncio.create_task(self._receive_loop()) async def close(self) -> None: + """Stop the receive loop and cancel any in-flight handler tasks.""" if not self._recv_task.done(): self._recv_task.cancel() with contextlib.suppress(asyncio.CancelledError): @@ -140,9 +149,19 @@ async def close(self) -> None: for task in tasks: with contextlib.suppress(asyncio.CancelledError): await task - # Do not close writer here; lifecycle owned by caller - # --- IO loops ---------------------------------------------------------------- + async def send_request(self, method: str, params: JsonValue | None = None) -> Any: + request_id = self._next_request_id + self._next_request_id += 1 + future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + self._pending[request_id] = _Pending(future) + payload = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} + await self._send_obj(payload) + return await future + + async def send_notification(self, method: str, params: JsonValue | None = None) -> None: + payload = {"jsonrpc": "2.0", "method": method, "params": params} + await self._send_obj(payload) async def _receive_loop(self) -> None: try: @@ -151,20 +170,17 @@ async def _receive_loop(self) -> None: if not line: break try: - message = json.loads(line) + message: dict[str, Any] = json.loads(line) except Exception: - # Align with Rust/TS: on parse error, do not send a response; just skip logging.exception("Error parsing JSON-RPC message") continue - await self._process_message(message) except asyncio.CancelledError: return - async def _process_message(self, message: dict) -> None: + async def _process_message(self, message: dict[str, Any]) -> None: method = message.get("method") has_id = "id" in message - if method is not None and has_id: self._schedule(self._handle_request(message)) return @@ -174,8 +190,8 @@ async def _process_message(self, message: dict) -> None: if has_id: await self._handle_response(message) - def _schedule(self, coro: Awaitable[Any]) -> None: - task = asyncio.create_task(coro) + def _schedule(self, coroutine: Awaitable[Any]) -> None: + task = asyncio.create_task(coroutine) self._inflight.add(task) task.add_done_callback(self._task_done) @@ -183,76 +199,58 @@ def _task_done(self, task: asyncio.Task[Any]) -> None: self._inflight.discard(task) if task.cancelled(): return - try: + with contextlib.suppress(Exception): task.result() - except Exception: - logging.exception("Unhandled error in JSON-RPC request handler") - async def _handle_request(self, message: dict) -> None: - """Handle JSON-RPC request.""" - payload = {"jsonrpc": "2.0", "id": message["id"]} + async def _handle_request(self, message: dict[str, Any]) -> None: + payload: dict[str, Any] = {"jsonrpc": "2.0", "id": message["id"]} try: result = await self._handler(message["method"], message.get("params"), False) if isinstance(result, BaseModel): result = result.model_dump() payload["result"] = result if result is not None else None - except RequestError as re: - payload["error"] = re.to_error_obj() - except ValidationError as ve: - payload["error"] = RequestError.invalid_params({"errors": ve.errors()}).to_error_obj() - except Exception as err: + except RequestError as exc: + payload["error"] = exc.to_error_obj() + except ValidationError as exc: + payload["error"] = RequestError.invalid_params({"errors": exc.errors()}).to_error_obj() + except Exception as exc: try: - data = json.loads(str(err)) + data = json.loads(str(exc)) except Exception: - data = {"details": str(err)} + data = {"details": str(exc)} payload["error"] = RequestError.internal_error(data).to_error_obj() await self._send_obj(payload) - async def _handle_notification(self, message: dict) -> None: - """Handle JSON-RPC notification.""" + async def _handle_notification(self, message: dict[str, Any]) -> None: with contextlib.suppress(Exception): - # Best-effort; notifications do not produce responses await self._handler(message["method"], message.get("params"), True) - async def _handle_response(self, message: dict) -> None: - """Handle JSON-RPC response.""" - fut = self._pending.pop(message["id"], None) - if fut is None: + async def _handle_response(self, message: dict[str, Any]) -> None: + pending = self._pending.pop(message["id"], None) + if pending is None: return if "result" in message: - fut.future.set_result(message.get("result")) - elif "error" in message: - err = message.get("error") or {} - fut.future.set_exception( - RequestError(err.get("code", -32603), err.get("message", "Error"), err.get("data")) + pending.future.set_result(message.get("result")) + return + if "error" in message: + error_obj = message.get("error") or {} + pending.future.set_exception( + RequestError( + error_obj.get("code", -32603), + error_obj.get("message", "Error"), + error_obj.get("data"), + ) ) - else: - fut.future.set_result(None) + return + pending.future.set_result(None) - async def _send_obj(self, obj: dict) -> None: - data = (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8") + async def _send_obj(self, payload: dict[str, Any]) -> None: + data = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8") async with self._write_lock: self._writer.write(data) with contextlib.suppress(ConnectionError, RuntimeError): - # Peer closed; let reader loop end naturally await self._writer.drain() - # --- Public API -------------------------------------------------------------- - - async def send_request(self, method: str, params: JsonValue | None = None) -> Any: - req_id = self._next_request_id - self._next_request_id += 1 - fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future() - self._pending[req_id] = _Pending(fut) - await self._send_obj({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}) - return await fut - - async def send_notification(self, method: str, params: JsonValue | None = None) -> None: - await self._send_obj({"jsonrpc": "2.0", "method": method, "params": params}) - - -# --- High-level Agent/Client wrappers ------------------------------------------- - class Client(Protocol): async def requestPermission(self, params: RequestPermissionRequest) -> RequestPermissionResponse: ... @@ -263,7 +261,6 @@ async def writeTextFile(self, params: WriteTextFileRequest) -> WriteTextFileResp async def readTextFile(self, params: ReadTextFileRequest) -> ReadTextFileResponse: ... - # Optional/unstable terminal methods async def createTerminal(self, params: CreateTerminalRequest) -> CreateTerminalResponse: ... async def terminalOutput(self, params: TerminalOutputRequest) -> TerminalOutputResponse: ... @@ -274,10 +271,9 @@ async def waitForTerminalExit(self, params: WaitForTerminalExitRequest) -> WaitF async def killTerminal(self, params: KillTerminalCommandRequest) -> KillTerminalCommandResponse | None: ... - # Extension hooks (optional) - async def extMethod(self, method: str, params: dict) -> dict: ... + async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: ... - async def extNotification(self, method: str, params: dict) -> None: ... + async def extNotification(self, method: str, params: dict[str, Any]) -> None: ... class Agent(Protocol): @@ -297,21 +293,13 @@ async def prompt(self, params: PromptRequest) -> PromptResponse: ... async def cancel(self, params: CancelNotification) -> None: ... - # Extension hooks (optional) - async def extMethod(self, method: str, params: dict) -> dict: ... + async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: ... - async def extNotification(self, method: str, params: dict) -> None: ... + async def extNotification(self, method: str, params: dict[str, Any]) -> None: ... class AgentSideConnection: - """ - Agent-side connection. Use when you implement the Agent and need to talk to a Client. - - Parameters: - - to_agent: factory that receives this connection and returns your Agent implementation - - input: asyncio.StreamWriter (local -> peer) - - output: asyncio.StreamReader (peer -> local) - """ + """Agent-side connection wrapper that dispatches JSON-RPC messages to a Client implementation.""" def __init__( self, @@ -320,150 +308,84 @@ def __init__( output_stream: Any, ) -> None: agent = to_agent(self) - - handler = self._create_agent_handler(agent) + handler = _create_agent_handler(agent) if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance(output_stream, asyncio.StreamReader): raise TypeError(_AGENT_CONNECTION_ERROR) self._conn = Connection(handler, input_stream, output_stream) - def _create_agent_handler(self, agent: Agent) -> MethodHandler: - async def handler(method: str, params: Any, is_notification: bool) -> Any: - return await self._handle_agent_method(agent, method, params, is_notification) - - return handler - - async def _handle_agent_method(self, agent: Agent, method: str, params: Any, is_notification: bool) -> Any: - # Init/new - result = await self._handle_agent_init_methods(agent, method, params) - if result is not _NO_MATCH: - return result - # Session-related - result = await self._handle_agent_session_methods(agent, method, params) - if result is not _NO_MATCH: - return result - # Auth - result = await self._handle_agent_auth_methods(agent, method, params) - if result is not _NO_MATCH: - return result - # Extensions - result = await self._handle_agent_ext_methods(agent, method, params, is_notification) - if result is not _NO_MATCH: - return result - raise RequestError.method_not_found(method) - - async def _handle_agent_init_methods(self, agent: Agent, method: str, params: Any) -> Any: - if method == AGENT_METHODS["initialize"]: - p = InitializeRequest.model_validate(params) - return await agent.initialize(p) - if method == AGENT_METHODS["session_new"]: - p = NewSessionRequest.model_validate(params) - return await agent.newSession(p) - return _NO_MATCH - - async def _handle_agent_session_methods(self, agent: Agent, method: str, params: Any) -> Any: - if method == AGENT_METHODS["session_load"]: - if not hasattr(agent, "loadSession"): - raise RequestError.method_not_found(method) - p = LoadSessionRequest.model_validate(params) - result = await agent.loadSession(p) - if isinstance(result, BaseModel): - return result.model_dump() - return result or {} - if method == AGENT_METHODS["session_set_mode"]: - if not hasattr(agent, "setSessionMode"): - raise RequestError.method_not_found(method) - p = SetSessionModeRequest.model_validate(params) - result = await agent.setSessionMode(p) - return result.model_dump() if isinstance(result, BaseModel) else (result or {}) - if method == AGENT_METHODS["session_prompt"]: - p = PromptRequest.model_validate(params) - return await agent.prompt(p) - if method == AGENT_METHODS["session_set_model"]: - if not hasattr(agent, "setSessionModel"): - raise RequestError.method_not_found(method) - p = SetSessionModelRequest.model_validate(params) - result = await agent.setSessionModel(p) - return result.model_dump() if isinstance(result, BaseModel) else (result or {}) - if method == AGENT_METHODS["session_cancel"]: - p = CancelNotification.model_validate(params) - return await agent.cancel(p) - return _NO_MATCH - - async def _handle_agent_auth_methods(self, agent: Agent, method: str, params: Any) -> Any: - if method == AGENT_METHODS["authenticate"]: - p = AuthenticateRequest.model_validate(params) - result = await agent.authenticate(p) - return result.model_dump() if isinstance(result, BaseModel) else (result or {}) - return _NO_MATCH - - async def _handle_agent_ext_methods(self, agent: Agent, method: str, params: Any, is_notification: bool) -> Any: - if isinstance(method, str) and method.startswith("_"): - ext_name = method[1:] - if is_notification: - if hasattr(agent, "extNotification"): - await agent.extNotification(ext_name, params or {}) # type: ignore[arg-type] - return None - return None - else: - if hasattr(agent, "extMethod"): - return await agent.extMethod(ext_name, params or {}) # type: ignore[arg-type] - return _NO_MATCH - return _NO_MATCH - - # client-bound methods (agent -> client) async def sessionUpdate(self, params: SessionNotification) -> None: await self._conn.send_notification( CLIENT_METHODS["session_update"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), ) async def requestPermission(self, params: RequestPermissionRequest) -> RequestPermissionResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( CLIENT_METHODS["session_request_permission"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), ) - return RequestPermissionResponse.model_validate(resp) + return RequestPermissionResponse.model_validate(response) async def readTextFile(self, params: ReadTextFileRequest) -> ReadTextFileResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( CLIENT_METHODS["fs_read_text_file"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), ) - return ReadTextFileResponse.model_validate(resp) + return ReadTextFileResponse.model_validate(response) async def writeTextFile(self, params: WriteTextFileRequest) -> WriteTextFileResponse | None: - resp = await self._conn.send_request( + response = await self._conn.send_request( CLIENT_METHODS["fs_write_text_file"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), ) - # Response may be empty object - return WriteTextFileResponse.model_validate(resp) if isinstance(resp, dict) else None + return WriteTextFileResponse.model_validate(response) if isinstance(response, dict) else None async def createTerminal(self, params: CreateTerminalRequest) -> TerminalHandle: - resp = await self._conn.send_request( + response = await self._conn.send_request( CLIENT_METHODS["terminal_create"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), + ) + create_response = CreateTerminalResponse.model_validate(response) + return TerminalHandle(create_response.terminalId, params.sessionId, self._conn) + + async def terminalOutput(self, params: TerminalOutputRequest) -> TerminalOutputResponse: + response = await self._conn.send_request( + CLIENT_METHODS["terminal_output"], + _dump_params(params), + ) + return TerminalOutputResponse.model_validate(response) + + async def releaseTerminal(self, params: ReleaseTerminalRequest) -> ReleaseTerminalResponse | None: + response = await self._conn.send_request( + CLIENT_METHODS["terminal_release"], + _dump_params(params), + ) + return ReleaseTerminalResponse.model_validate(response) if isinstance(response, dict) else None + + async def waitForTerminalExit(self, params: WaitForTerminalExitRequest) -> WaitForTerminalExitResponse: + response = await self._conn.send_request( + CLIENT_METHODS["terminal_wait_for_exit"], + _dump_params(params), + ) + return WaitForTerminalExitResponse.model_validate(response) + + async def killTerminal(self, params: KillTerminalCommandRequest) -> KillTerminalCommandResponse | None: + response = await self._conn.send_request( + CLIENT_METHODS["terminal_kill"], + _dump_params(params), ) - create_resp = CreateTerminalResponse.model_validate(resp) - return TerminalHandle(create_resp.terminalId, params.sessionId, self._conn) + return KillTerminalCommandResponse.model_validate(response) if isinstance(response, dict) else None - async def extMethod(self, method: str, params: dict) -> dict: + async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: return await self._conn.send_request(f"_{method}", params) - async def extNotification(self, method: str, params: dict) -> None: + async def extNotification(self, method: str, params: dict[str, Any]) -> None: await self._conn.send_notification(f"_{method}", params) class ClientSideConnection: - """ - Client-side connection. Use when you implement the Client and need to talk to an Agent. - - Parameters: - - to_client: factory that receives this connection and returns your Client implementation - - input: asyncio.StreamWriter (local -> peer) - - output: asyncio.StreamReader (peer -> local) - """ + """Client-side connection wrapper that dispatches JSON-RPC messages to an Agent implementation.""" def __init__( self, @@ -474,172 +396,73 @@ def __init__( if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance(output_stream, asyncio.StreamReader): raise TypeError(_CLIENT_CONNECTION_ERROR) - # Build client first so handler can delegate client = to_client(self) # type: ignore[arg-type] - handler = self._create_handler(client) + handler = _create_client_handler(client) self._conn = Connection(handler, input_stream, output_stream) - def _create_handler(self, client: Client) -> MethodHandler: - """Create the method handler for client-side connection.""" - - async def handler(method: str, params: Any, is_notification: bool) -> Any: - return await self._handle_client_method(client, method, params, is_notification) - - return handler - - async def _handle_client_method(self, client: Client, method: str, params: Any, is_notification: bool) -> Any: - """Handle client method calls.""" - # Core session/file methods - result = await self._handle_client_core_methods(client, method, params) - if result is not _NO_MATCH: - return result - # Terminal methods - result = await self._handle_client_terminal_methods(client, method, params) - if result is not _NO_MATCH: - return result - # Extension methods/notifications - result = await self._handle_client_extension_methods(client, method, params, is_notification) - if result is not _NO_MATCH: - return result - raise RequestError.method_not_found(method) - - async def _handle_client_core_methods(self, client: Client, method: str, params: Any) -> Any: - if method == CLIENT_METHODS["fs_write_text_file"]: - p = WriteTextFileRequest.model_validate(params) - return await client.writeTextFile(p) - if method == CLIENT_METHODS["fs_read_text_file"]: - p = ReadTextFileRequest.model_validate(params) - return await client.readTextFile(p) - if method == CLIENT_METHODS["session_request_permission"]: - p = RequestPermissionRequest.model_validate(params) - return await client.requestPermission(p) - if method == CLIENT_METHODS["session_update"]: - p = SessionNotification.model_validate(params) - return await client.sessionUpdate(p) - return _NO_MATCH - - async def _handle_client_terminal_methods(self, client: Client, method: str, params: Any) -> Any: - result = await self._handle_client_terminal_basic(client, method, params) - if result is not _NO_MATCH: - return result - result = await self._handle_client_terminal_lifecycle(client, method, params) - if result is not _NO_MATCH: - return result - return _NO_MATCH - - async def _handle_client_terminal_basic(self, client: Client, method: str, params: Any) -> Any: - if method == CLIENT_METHODS["terminal_create"]: - if hasattr(client, "createTerminal"): - p = CreateTerminalRequest.model_validate(params) - return await client.createTerminal(p) - return None # TS returns null when optional method missing - if method == CLIENT_METHODS["terminal_output"]: - if hasattr(client, "terminalOutput"): - p = TerminalOutputRequest.model_validate(params) - return await client.terminalOutput(p) - return None - return _NO_MATCH - - async def _handle_client_terminal_lifecycle(self, client: Client, method: str, params: Any) -> Any: - if method == CLIENT_METHODS["terminal_release"]: - if hasattr(client, "releaseTerminal"): - p = ReleaseTerminalRequest.model_validate(params) - result = await client.releaseTerminal(p) - return result.model_dump() if isinstance(result, BaseModel) else (result or {}) - return {} # TS returns {} for void optional methods - if method == CLIENT_METHODS["terminal_wait_for_exit"]: - if hasattr(client, "waitForTerminalExit"): - p = WaitForTerminalExitRequest.model_validate(params) - return await client.waitForTerminalExit(p) - return None - if method == CLIENT_METHODS["terminal_kill"]: - if hasattr(client, "killTerminal"): - p = KillTerminalCommandRequest.model_validate(params) - result = await client.killTerminal(p) - return result.model_dump() if isinstance(result, BaseModel) else (result or {}) - return {} # TS returns {} for void optional methods - return _NO_MATCH - - async def _handle_client_extension_methods( - self, client: Client, method: str, params: Any, is_notification: bool - ) -> Any: - if isinstance(method, str) and method.startswith("_"): - ext_name = method[1:] - if is_notification: - if hasattr(client, "extNotification"): - await client.extNotification(ext_name, params or {}) # type: ignore[arg-type] - return None - return None - else: - if hasattr(client, "extMethod"): - return await client.extMethod(ext_name, params or {}) # type: ignore[arg-type] - return _NO_MATCH - return _NO_MATCH - - # agent-bound methods (client -> agent) async def initialize(self, params: InitializeRequest) -> InitializeResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( AGENT_METHODS["initialize"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), ) - return InitializeResponse.model_validate(resp) + return InitializeResponse.model_validate(response) async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( AGENT_METHODS["session_new"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), ) - return NewSessionResponse.model_validate(resp) + return NewSessionResponse.model_validate(response) async def loadSession(self, params: LoadSessionRequest) -> LoadSessionResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( AGENT_METHODS["session_load"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), ) - payload = resp if isinstance(resp, dict) else {} + payload = response if isinstance(response, dict) else {} return LoadSessionResponse.model_validate(payload) async def setSessionMode(self, params: SetSessionModeRequest) -> SetSessionModeResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( AGENT_METHODS["session_set_mode"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), ) - payload = resp if isinstance(resp, dict) else {} + payload = response if isinstance(response, dict) else {} return SetSessionModeResponse.model_validate(payload) async def setSessionModel(self, params: SetSessionModelRequest) -> SetSessionModelResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( AGENT_METHODS["session_set_model"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), ) - payload = resp if isinstance(resp, dict) else {} + payload = response if isinstance(response, dict) else {} return SetSessionModelResponse.model_validate(payload) async def authenticate(self, params: AuthenticateRequest) -> AuthenticateResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( AGENT_METHODS["authenticate"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), ) - payload = resp if isinstance(resp, dict) else {} + payload = response if isinstance(response, dict) else {} return AuthenticateResponse.model_validate(payload) async def prompt(self, params: PromptRequest) -> PromptResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( AGENT_METHODS["session_prompt"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), ) - return PromptResponse.model_validate(resp) + return PromptResponse.model_validate(response) async def cancel(self, params: CancelNotification) -> None: await self._conn.send_notification( AGENT_METHODS["session_cancel"], - params.model_dump(exclude_none=True, exclude_defaults=True), + _dump_params(params), ) - async def extMethod(self, method: str, params: dict) -> dict: + async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: return await self._conn.send_request(f"_{method}", params) - async def extNotification(self, method: str, params: dict) -> None: + async def extNotification(self, method: str, params: dict[str, Any]) -> None: await self._conn.send_notification(f"_{method}", params) @@ -650,31 +473,200 @@ def __init__(self, terminal_id: str, session_id: str, conn: Connection) -> None: self._conn = conn async def current_output(self) -> TerminalOutputResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( CLIENT_METHODS["terminal_output"], {"sessionId": self._session_id, "terminalId": self.id}, ) - return TerminalOutputResponse.model_validate(resp) + return TerminalOutputResponse.model_validate(response) async def wait_for_exit(self) -> WaitForTerminalExitResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( CLIENT_METHODS["terminal_wait_for_exit"], {"sessionId": self._session_id, "terminalId": self.id}, ) - return WaitForTerminalExitResponse.model_validate(resp) + return WaitForTerminalExitResponse.model_validate(response) async def kill(self) -> KillTerminalCommandResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( CLIENT_METHODS["terminal_kill"], {"sessionId": self._session_id, "terminalId": self.id}, ) - payload = resp if isinstance(resp, dict) else {} + payload = response if isinstance(response, dict) else {} return KillTerminalCommandResponse.model_validate(payload) async def release(self) -> ReleaseTerminalResponse: - resp = await self._conn.send_request( + response = await self._conn.send_request( CLIENT_METHODS["terminal_release"], {"sessionId": self._session_id, "terminalId": self.id}, ) - payload = resp if isinstance(resp, dict) else {} + payload = response if isinstance(response, dict) else {} return ReleaseTerminalResponse.model_validate(payload) + + +async def _handle_agent_init_methods(agent: Agent, method: str, params: Any | None) -> Any: + if method == AGENT_METHODS["initialize"]: + request = InitializeRequest.model_validate(params) + return await agent.initialize(request) + if method == AGENT_METHODS["session_new"]: + request = NewSessionRequest.model_validate(params) + return await agent.newSession(request) + return _NO_MATCH + + +async def _handle_agent_session_methods(agent: Agent, method: str, params: Any | None) -> Any: + if method == AGENT_METHODS["session_load"]: + if not hasattr(agent, "loadSession"): + raise RequestError.method_not_found(method) + request = LoadSessionRequest.model_validate(params) + result = await agent.loadSession(request) + return _optional_result(result) + if method == AGENT_METHODS["session_set_mode"]: + if not hasattr(agent, "setSessionMode"): + raise RequestError.method_not_found(method) + request = SetSessionModeRequest.model_validate(params) + result = await agent.setSessionMode(request) + return _optional_result(result) + if method == AGENT_METHODS["session_prompt"]: + request = PromptRequest.model_validate(params) + return await agent.prompt(request) + if method == AGENT_METHODS["session_set_model"]: + if not hasattr(agent, "setSessionModel"): + raise RequestError.method_not_found(method) + request = SetSessionModelRequest.model_validate(params) + result = await agent.setSessionModel(request) + return _optional_result(result) + if method == AGENT_METHODS["session_cancel"]: + request = CancelNotification.model_validate(params) + return await agent.cancel(request) + return _NO_MATCH + + +async def _handle_agent_auth_methods(agent: Agent, method: str, params: Any | None) -> Any: + if method == AGENT_METHODS["authenticate"]: + if not hasattr(agent, "authenticate"): + raise RequestError.method_not_found(method) + request = AuthenticateRequest.model_validate(params) + result = await agent.authenticate(request) + return _optional_result(result) + return _NO_MATCH + + +async def _handle_agent_extension_methods(agent: Agent, method: str, params: Any | None, is_notification: bool) -> Any: + if isinstance(method, str) and method.startswith("_"): + ext_name = method[1:] + if is_notification: + if hasattr(agent, "extNotification"): + await agent.extNotification(ext_name, params or {}) # type: ignore[arg-type] + return None + return None + if hasattr(agent, "extMethod"): + return await agent.extMethod(ext_name, params or {}) # type: ignore[arg-type] + return _NO_MATCH + return _NO_MATCH + + +async def _handle_agent_method(agent: Agent, method: str, params: Any | None, is_notification: bool) -> Any: + for resolver in ( + _handle_agent_init_methods, + _handle_agent_session_methods, + _handle_agent_auth_methods, + ): + result = await resolver(agent, method, params) + if result is not _NO_MATCH: + return result + ext_result = await _handle_agent_extension_methods(agent, method, params, is_notification) + if ext_result is not _NO_MATCH: + return ext_result + raise RequestError.method_not_found(method) + + +def _create_agent_handler(agent: Agent) -> MethodHandler: + async def handler(method: str, params: Any | None, is_notification: bool) -> Any: + return await _handle_agent_method(agent, method, params, is_notification) + + return handler + + +async def _handle_client_core_methods(client: Client, method: str, params: Any | None) -> Any: + if method == CLIENT_METHODS["fs_write_text_file"]: + request = WriteTextFileRequest.model_validate(params) + return await client.writeTextFile(request) + if method == CLIENT_METHODS["fs_read_text_file"]: + request = ReadTextFileRequest.model_validate(params) + return await client.readTextFile(request) + if method == CLIENT_METHODS["session_request_permission"]: + request = RequestPermissionRequest.model_validate(params) + return await client.requestPermission(request) + if method == CLIENT_METHODS["session_update"]: + notification = SessionNotification.model_validate(params) + await client.sessionUpdate(notification) + return None + return _NO_MATCH + + +async def _handle_client_terminal_methods(client: Client, method: str, params: Any | None) -> Any: # noqa: C901 + if method == CLIENT_METHODS["terminal_create"]: + if not hasattr(client, "createTerminal"): + return None + request = CreateTerminalRequest.model_validate(params) + return await client.createTerminal(request) + if method == CLIENT_METHODS["terminal_output"]: + if not hasattr(client, "terminalOutput"): + return None + request = TerminalOutputRequest.model_validate(params) + return await client.terminalOutput(request) + if method == CLIENT_METHODS["terminal_release"]: + if not hasattr(client, "releaseTerminal"): + return {} + request = ReleaseTerminalRequest.model_validate(params) + result = await client.releaseTerminal(request) + return _optional_result(result) + if method == CLIENT_METHODS["terminal_wait_for_exit"]: + if not hasattr(client, "waitForTerminalExit"): + return None + request = WaitForTerminalExitRequest.model_validate(params) + return await client.waitForTerminalExit(request) + if method == CLIENT_METHODS["terminal_kill"]: + if not hasattr(client, "killTerminal"): + return {} + request = KillTerminalCommandRequest.model_validate(params) + result = await client.killTerminal(request) + return _optional_result(result) + return _NO_MATCH + + +async def _handle_client_extension_methods( + client: Client, method: str, params: Any | None, is_notification: bool +) -> Any: + if isinstance(method, str) and method.startswith("_"): + ext_name = method[1:] + if is_notification: + if hasattr(client, "extNotification"): + await client.extNotification(ext_name, params or {}) # type: ignore[arg-type] + return None + return None + if hasattr(client, "extMethod"): + return await client.extMethod(ext_name, params or {}) # type: ignore[arg-type] + return _NO_MATCH + return _NO_MATCH + + +async def _handle_client_method(client: Client, method: str, params: Any | None, is_notification: bool) -> Any: + for resolver in ( + _handle_client_core_methods, + _handle_client_terminal_methods, + ): + result = await resolver(client, method, params) + if result is not _NO_MATCH: + return result + ext_result = await _handle_client_extension_methods(client, method, params, is_notification) + if ext_result is not _NO_MATCH: + return ext_result + raise RequestError.method_not_found(method) + + +def _create_client_handler(client: Client) -> MethodHandler: + async def handler(method: str, params: Any | None, is_notification: bool) -> Any: + return await _handle_client_method(client, method, params, is_notification) + + return handler diff --git a/tests/real_user/__init__.py b/tests/real_user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cancel_prompt_flow.py b/tests/real_user/test_cancel_prompt_flow.py similarity index 90% rename from tests/test_cancel_prompt_flow.py rename to tests/real_user/test_cancel_prompt_flow.py index c43263e..45d7798 100644 --- a/tests/test_cancel_prompt_flow.py +++ b/tests/real_user/test_cancel_prompt_flow.py @@ -2,16 +2,12 @@ import pytest -from acp import ( - AgentSideConnection, - CancelNotification, - ClientSideConnection, - PromptRequest, - PromptResponse, -) +from acp import AgentSideConnection, CancelNotification, ClientSideConnection, PromptRequest, PromptResponse from acp.schema import TextContentBlock from tests.test_rpc import TestAgent, TestClient, _Server +# Regression from a real user session where cancel needed to interrupt a long-running prompt. + class LongRunningAgent(TestAgent): """Agent variant whose prompt waits for a cancel notification.""" diff --git a/tests/test_permission_flow.py b/tests/real_user/test_permission_flow.py similarity index 88% rename from tests/test_permission_flow.py rename to tests/real_user/test_permission_flow.py index a4478b3..7987b7a 100644 --- a/tests/test_permission_flow.py +++ b/tests/real_user/test_permission_flow.py @@ -2,17 +2,12 @@ import pytest -from acp import ( - AgentSideConnection, - ClientSideConnection, - PromptRequest, - PromptResponse, - RequestPermissionRequest, - RequestPermissionResponse, -) +from acp import AgentSideConnection, ClientSideConnection, PromptRequest, PromptResponse, RequestPermissionRequest from acp.schema import PermissionOption, TextContentBlock, ToolCallUpdate from tests.test_rpc import TestAgent, TestClient, _Server +# Regression from real-world runs where agents paused prompts to obtain user permission. + class PermissionRequestAgent(TestAgent): """Agent that asks the client for permission during a prompt.""" @@ -20,7 +15,7 @@ class PermissionRequestAgent(TestAgent): def __init__(self, conn: AgentSideConnection) -> None: super().__init__() self._conn = conn - self.permission_responses: list[RequestPermissionResponse] = [] + self.permission_responses = [] async def prompt(self, params: PromptRequest) -> PromptResponse: permission = await self._conn.requestPermission( @@ -43,7 +38,7 @@ async def test_agent_request_permission_roundtrip() -> None: client = TestClient() client.queue_permission_selected("allow") - captured_agent: list[PermissionRequestAgent] = [] + captured_agent = [] agent_conn = ClientSideConnection(lambda _conn: client, server.client_writer, server.client_reader) _agent_conn = AgentSideConnection( From 76fcfc6dc29180a7e93c8f2fc6fe3c3692f1e596 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 11 Oct 2025 03:19:05 +0800 Subject: [PATCH 2/6] refactor: split into files Signed-off-by: Chojan Shang --- src/acp/agent/__init__.py | 3 + src/acp/agent/connection.py | 132 +++++++ src/acp/agent/handlers.py | 104 ++++++ src/acp/client/__init__.py | 3 + src/acp/client/connection.py | 124 +++++++ src/acp/client/handlers.py | 102 ++++++ src/acp/connection.py | 158 ++++++++ src/acp/core.py | 693 ++--------------------------------- src/acp/exceptions.py | 46 +++ src/acp/interfaces.py | 86 +++++ src/acp/terminal.py | 49 +++ src/acp/utils.py | 96 +++++ 12 files changed, 927 insertions(+), 669 deletions(-) create mode 100644 src/acp/agent/__init__.py create mode 100644 src/acp/agent/connection.py create mode 100644 src/acp/agent/handlers.py create mode 100644 src/acp/client/__init__.py create mode 100644 src/acp/client/connection.py create mode 100644 src/acp/client/handlers.py create mode 100644 src/acp/connection.py create mode 100644 src/acp/exceptions.py create mode 100644 src/acp/interfaces.py create mode 100644 src/acp/terminal.py create mode 100644 src/acp/utils.py diff --git a/src/acp/agent/__init__.py b/src/acp/agent/__init__.py new file mode 100644 index 0000000..5513dc5 --- /dev/null +++ b/src/acp/agent/__init__.py @@ -0,0 +1,3 @@ +from .connection import AgentSideConnection + +__all__ = ["AgentSideConnection"] diff --git a/src/acp/agent/connection.py b/src/acp/agent/connection.py new file mode 100644 index 0000000..3b1b301 --- /dev/null +++ b/src/acp/agent/connection.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +from ..connection import Connection, MethodHandler +from ..interfaces import Agent +from ..meta import CLIENT_METHODS +from ..schema import ( + CreateTerminalRequest, + CreateTerminalResponse, + KillTerminalCommandRequest, + KillTerminalCommandResponse, + ReadTextFileRequest, + ReadTextFileResponse, + ReleaseTerminalRequest, + ReleaseTerminalResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + TerminalOutputRequest, + TerminalOutputResponse, + WaitForTerminalExitRequest, + WaitForTerminalExitResponse, + WriteTextFileRequest, + WriteTextFileResponse, +) +from ..terminal import TerminalHandle +from ..utils import notify_model, request_model, request_optional_model +from .handlers import dispatch_agent_method + +__all__ = ["AgentSideConnection"] + +_AGENT_CONNECTION_ERROR = "AgentSideConnection requires asyncio StreamWriter/StreamReader" + + +class AgentSideConnection: + """Agent-side connection wrapper that dispatches JSON-RPC messages to a Client implementation.""" + + def __init__( + self, + to_agent: Callable[[AgentSideConnection], Agent], + input_stream: Any, + output_stream: Any, + ) -> None: + agent = to_agent(self) + handler = self._create_handler(agent) + + if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance(output_stream, asyncio.StreamReader): + raise TypeError(_AGENT_CONNECTION_ERROR) + self._conn = Connection(handler, input_stream, output_stream) + + def _create_handler(self, agent: Agent) -> MethodHandler: + async def handler(method: str, params: Any | None, is_notification: bool) -> Any: + return await dispatch_agent_method(agent, method, params, is_notification) + + return handler + + async def sessionUpdate(self, params: SessionNotification) -> None: + await notify_model(self._conn, CLIENT_METHODS["session_update"], params) + + async def requestPermission(self, params: RequestPermissionRequest) -> RequestPermissionResponse: + return await request_model( + self._conn, + CLIENT_METHODS["session_request_permission"], + params, + RequestPermissionResponse, + ) + + async def readTextFile(self, params: ReadTextFileRequest) -> ReadTextFileResponse: + return await request_model( + self._conn, + CLIENT_METHODS["fs_read_text_file"], + params, + ReadTextFileResponse, + ) + + async def writeTextFile(self, params: WriteTextFileRequest) -> WriteTextFileResponse | None: + return await request_optional_model( + self._conn, + CLIENT_METHODS["fs_write_text_file"], + params, + WriteTextFileResponse, + ) + + async def createTerminal(self, params: CreateTerminalRequest) -> TerminalHandle: + create_response = await request_model( + self._conn, + CLIENT_METHODS["terminal_create"], + params, + CreateTerminalResponse, + ) + return TerminalHandle(create_response.terminalId, params.sessionId, self._conn) + + async def terminalOutput(self, params: TerminalOutputRequest) -> TerminalOutputResponse: + return await request_model( + self._conn, + CLIENT_METHODS["terminal_output"], + params, + TerminalOutputResponse, + ) + + async def releaseTerminal(self, params: ReleaseTerminalRequest) -> ReleaseTerminalResponse | None: + return await request_optional_model( + self._conn, + CLIENT_METHODS["terminal_release"], + params, + ReleaseTerminalResponse, + ) + + async def waitForTerminalExit(self, params: WaitForTerminalExitRequest) -> WaitForTerminalExitResponse: + return await request_model( + self._conn, + CLIENT_METHODS["terminal_wait_for_exit"], + params, + WaitForTerminalExitResponse, + ) + + async def killTerminal(self, params: KillTerminalCommandRequest) -> KillTerminalCommandResponse | None: + return await request_optional_model( + self._conn, + CLIENT_METHODS["terminal_kill"], + params, + KillTerminalCommandResponse, + ) + + async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + return await self._conn.send_request(f"_{method}", params) + + async def extNotification(self, method: str, params: dict[str, Any]) -> None: + await self._conn.send_notification(f"_{method}", params) diff --git a/src/acp/agent/handlers.py b/src/acp/agent/handlers.py new file mode 100644 index 0000000..0ee6267 --- /dev/null +++ b/src/acp/agent/handlers.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import Any + +from ..exceptions import RequestError +from ..interfaces import Agent +from ..meta import AGENT_METHODS +from ..schema import ( + AuthenticateRequest, + CancelNotification, + InitializeRequest, + LoadSessionRequest, + NewSessionRequest, + PromptRequest, + SetSessionModelRequest, + SetSessionModeRequest, +) +from ..utils import normalize_result + +__all__ = [ + "NO_MATCH", + "dispatch_agent_method", +] + + +class _NoMatch: + """Sentinel returned by routing helpers when no handler matches.""" + + +NO_MATCH = _NoMatch() + + +async def _handle_agent_init(agent: Agent, method: str, params: Any | None) -> Any: + if method == AGENT_METHODS["initialize"]: + request = InitializeRequest.model_validate(params) + return await agent.initialize(request) + if method == AGENT_METHODS["session_new"]: + request = NewSessionRequest.model_validate(params) + return await agent.newSession(request) + return NO_MATCH + + +async def _handle_agent_session(agent: Agent, method: str, params: Any | None) -> Any: + if method == AGENT_METHODS["session_load"]: + if not hasattr(agent, "loadSession"): + raise RequestError.method_not_found(method) + request = LoadSessionRequest.model_validate(params) + result = await agent.loadSession(request) + return normalize_result(result) + if method == AGENT_METHODS["session_set_mode"]: + if not hasattr(agent, "setSessionMode"): + raise RequestError.method_not_found(method) + request = SetSessionModeRequest.model_validate(params) + result = await agent.setSessionMode(request) + return normalize_result(result) + if method == AGENT_METHODS["session_prompt"]: + request = PromptRequest.model_validate(params) + return await agent.prompt(request) + if method == AGENT_METHODS["session_set_model"]: + if not hasattr(agent, "setSessionModel"): + raise RequestError.method_not_found(method) + request = SetSessionModelRequest.model_validate(params) + result = await agent.setSessionModel(request) + return normalize_result(result) + if method == AGENT_METHODS["session_cancel"]: + request = CancelNotification.model_validate(params) + return await agent.cancel(request) + return NO_MATCH + + +async def _handle_agent_auth(agent: Agent, method: str, params: Any | None) -> Any: + if method == AGENT_METHODS["authenticate"]: + if not hasattr(agent, "authenticate"): + raise RequestError.method_not_found(method) + request = AuthenticateRequest.model_validate(params) + result = await agent.authenticate(request) + return normalize_result(result) + return NO_MATCH + + +async def _handle_agent_extensions(agent: Agent, method: str, params: Any | None, is_notification: bool) -> Any: + if isinstance(method, str) and method.startswith("_"): + ext_name = method[1:] + if is_notification: + if hasattr(agent, "extNotification"): + await agent.extNotification(ext_name, params or {}) # type: ignore[arg-type] + return None + return None + if hasattr(agent, "extMethod"): + return await agent.extMethod(ext_name, params or {}) # type: ignore[arg-type] + return NO_MATCH + return NO_MATCH + + +async def dispatch_agent_method(agent: Agent, method: str, params: Any | None, is_notification: bool) -> Any: + """Dispatch agent-bound methods, mirroring the upstream ACP routing.""" + for resolver in (_handle_agent_init, _handle_agent_session, _handle_agent_auth): + result = await resolver(agent, method, params) + if result is not NO_MATCH: + return result + extension_result = await _handle_agent_extensions(agent, method, params, is_notification) + if extension_result is not NO_MATCH: + return extension_result + raise RequestError.method_not_found(method) diff --git a/src/acp/client/__init__.py b/src/acp/client/__init__.py new file mode 100644 index 0000000..829c56f --- /dev/null +++ b/src/acp/client/__init__.py @@ -0,0 +1,3 @@ +from .connection import ClientSideConnection + +__all__ = ["ClientSideConnection"] diff --git a/src/acp/client/connection.py b/src/acp/client/connection.py new file mode 100644 index 0000000..28e6211 --- /dev/null +++ b/src/acp/client/connection.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +from ..connection import Connection, MethodHandler +from ..interfaces import Agent, Client +from ..meta import AGENT_METHODS +from ..schema import ( + AuthenticateRequest, + AuthenticateResponse, + CancelNotification, + InitializeRequest, + InitializeResponse, + LoadSessionRequest, + LoadSessionResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + SetSessionModelRequest, + SetSessionModelResponse, + SetSessionModeRequest, + SetSessionModeResponse, +) +from ..utils import ( + notify_model, + request_model, + request_model_from_dict, +) +from .handlers import dispatch_client_method + +__all__ = ["ClientSideConnection"] + +_CLIENT_CONNECTION_ERROR = "ClientSideConnection requires asyncio StreamWriter/StreamReader" + + +class ClientSideConnection: + """Client-side connection wrapper that dispatches JSON-RPC messages to an Agent implementation.""" + + def __init__( + self, + to_client: Callable[[Agent], Client], + input_stream: Any, + output_stream: Any, + ) -> None: + if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance(output_stream, asyncio.StreamReader): + raise TypeError(_CLIENT_CONNECTION_ERROR) + + client = to_client(self) # type: ignore[arg-type] + handler = self._create_handler(client) + self._conn = Connection(handler, input_stream, output_stream) + + def _create_handler(self, client: Client) -> MethodHandler: + async def handler(method: str, params: Any | None, is_notification: bool) -> Any: + return await dispatch_client_method(client, method, params, is_notification) + + return handler + + async def initialize(self, params: InitializeRequest) -> InitializeResponse: + return await request_model( + self._conn, + AGENT_METHODS["initialize"], + params, + InitializeResponse, + ) + + async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: + return await request_model( + self._conn, + AGENT_METHODS["session_new"], + params, + NewSessionResponse, + ) + + async def loadSession(self, params: LoadSessionRequest) -> LoadSessionResponse: + return await request_model_from_dict( + self._conn, + AGENT_METHODS["session_load"], + params, + LoadSessionResponse, + ) + + async def setSessionMode(self, params: SetSessionModeRequest) -> SetSessionModeResponse: + return await request_model_from_dict( + self._conn, + AGENT_METHODS["session_set_mode"], + params, + SetSessionModeResponse, + ) + + async def setSessionModel(self, params: SetSessionModelRequest) -> SetSessionModelResponse: + return await request_model_from_dict( + self._conn, + AGENT_METHODS["session_set_model"], + params, + SetSessionModelResponse, + ) + + async def authenticate(self, params: AuthenticateRequest) -> AuthenticateResponse: + return await request_model_from_dict( + self._conn, + AGENT_METHODS["authenticate"], + params, + AuthenticateResponse, + ) + + async def prompt(self, params: PromptRequest) -> PromptResponse: + return await request_model( + self._conn, + AGENT_METHODS["session_prompt"], + params, + PromptResponse, + ) + + async def cancel(self, params: CancelNotification) -> None: + await notify_model(self._conn, AGENT_METHODS["session_cancel"], params) + + async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + return await self._conn.send_request(f"_{method}", params) + + async def extNotification(self, method: str, params: dict[str, Any]) -> None: + await self._conn.send_notification(f"_{method}", params) diff --git a/src/acp/client/handlers.py b/src/acp/client/handlers.py new file mode 100644 index 0000000..7515d13 --- /dev/null +++ b/src/acp/client/handlers.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from typing import Any + +from ..exceptions import RequestError +from ..interfaces import Client +from ..meta import CLIENT_METHODS +from ..schema import ( + CreateTerminalRequest, + KillTerminalCommandRequest, + ReadTextFileRequest, + ReleaseTerminalRequest, + RequestPermissionRequest, + SessionNotification, + TerminalOutputRequest, + WaitForTerminalExitRequest, + WriteTextFileRequest, +) +from ..utils import normalize_result + +__all__ = ["NO_MATCH", "dispatch_client_method"] + + +class _NoMatch: + """Sentinel returned by routing helpers when no handler matches.""" + + +NO_MATCH = _NoMatch() + + +async def _handle_client_core(client: Client, method: str, params: Any | None) -> Any: + if method == CLIENT_METHODS["fs_write_text_file"]: + request = WriteTextFileRequest.model_validate(params) + return await client.writeTextFile(request) + if method == CLIENT_METHODS["fs_read_text_file"]: + request = ReadTextFileRequest.model_validate(params) + return await client.readTextFile(request) + if method == CLIENT_METHODS["session_request_permission"]: + request = RequestPermissionRequest.model_validate(params) + return await client.requestPermission(request) + if method == CLIENT_METHODS["session_update"]: + notification = SessionNotification.model_validate(params) + await client.sessionUpdate(notification) + return None + return NO_MATCH + + +async def _handle_client_terminal(client: Client, method: str, params: Any | None) -> Any: # noqa: C901 + if method == CLIENT_METHODS["terminal_create"]: + if not hasattr(client, "createTerminal"): + return None + request = CreateTerminalRequest.model_validate(params) + return await client.createTerminal(request) + if method == CLIENT_METHODS["terminal_output"]: + if not hasattr(client, "terminalOutput"): + return None + request = TerminalOutputRequest.model_validate(params) + return await client.terminalOutput(request) + if method == CLIENT_METHODS["terminal_release"]: + if not hasattr(client, "releaseTerminal"): + return {} + request = ReleaseTerminalRequest.model_validate(params) + result = await client.releaseTerminal(request) + return normalize_result(result) + if method == CLIENT_METHODS["terminal_wait_for_exit"]: + if not hasattr(client, "waitForTerminalExit"): + return None + request = WaitForTerminalExitRequest.model_validate(params) + return await client.waitForTerminalExit(request) + if method == CLIENT_METHODS["terminal_kill"]: + if not hasattr(client, "killTerminal"): + return {} + request = KillTerminalCommandRequest.model_validate(params) + result = await client.killTerminal(request) + return normalize_result(result) + return NO_MATCH + + +async def _handle_client_extensions(client: Client, method: str, params: Any | None, is_notification: bool) -> Any: + if isinstance(method, str) and method.startswith("_"): + ext_name = method[1:] + if is_notification: + if hasattr(client, "extNotification"): + await client.extNotification(ext_name, params or {}) # type: ignore[arg-type] + return None + return None + if hasattr(client, "extMethod"): + return await client.extMethod(ext_name, params or {}) # type: ignore[arg-type] + return NO_MATCH + return NO_MATCH + + +async def dispatch_client_method(client: Client, method: str, params: Any | None, is_notification: bool) -> Any: + """Dispatch client-bound methods mirroring upstream ACP routing.""" + for resolver in (_handle_client_core, _handle_client_terminal): + result = await resolver(client, method, params) + if result is not NO_MATCH: + return result + extension_result = await _handle_client_extensions(client, method, params, is_notification) + if extension_result is not NO_MATCH: + return extension_result + raise RequestError.method_not_found(method) diff --git a/src/acp/connection.py b/src/acp/connection.py new file mode 100644 index 0000000..51be72b --- /dev/null +++ b/src/acp/connection.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel, ValidationError + +from .exceptions import RequestError + +JsonValue = Any +MethodHandler = Callable[[str, JsonValue | None, bool], Awaitable[JsonValue | None]] + + +__all__ = ["Connection", "JsonValue", "MethodHandler"] + + +@dataclass(slots=True) +class _Pending: + future: asyncio.Future[Any] + + +class Connection: + """Minimal JSON-RPC 2.0 connection over newline-delimited JSON frames.""" + + def __init__( + self, + handler: MethodHandler, + writer: asyncio.StreamWriter, + reader: asyncio.StreamReader, + ) -> None: + self._handler = handler + self._writer = writer + self._reader = reader + self._next_request_id = 0 + self._pending: dict[int, _Pending] = {} + self._inflight: set[asyncio.Task[Any]] = set() + self._write_lock = asyncio.Lock() + self._recv_task = asyncio.create_task(self._receive_loop()) + + async def close(self) -> None: + """Stop the receive loop and cancel any in-flight handler tasks.""" + if not self._recv_task.done(): + self._recv_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._recv_task + if self._inflight: + tasks = list(self._inflight) + for task in tasks: + task.cancel() + for task in tasks: + with contextlib.suppress(asyncio.CancelledError): + await task + + async def send_request(self, method: str, params: JsonValue | None = None) -> Any: + request_id = self._next_request_id + self._next_request_id += 1 + future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + self._pending[request_id] = _Pending(future) + payload = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} + await self._send_obj(payload) + return await future + + async def send_notification(self, method: str, params: JsonValue | None = None) -> None: + payload = {"jsonrpc": "2.0", "method": method, "params": params} + await self._send_obj(payload) + + async def _receive_loop(self) -> None: + try: + while True: + line = await self._reader.readline() + if not line: + break + try: + message: dict[str, Any] = json.loads(line) + except Exception: + logging.exception("Error parsing JSON-RPC message") + continue + await self._process_message(message) + except asyncio.CancelledError: + return + + async def _process_message(self, message: dict[str, Any]) -> None: + method = message.get("method") + has_id = "id" in message + if method is not None and has_id: + self._schedule(self._handle_request(message)) + return + if method is not None and not has_id: + await self._handle_notification(message) + return + if has_id: + await self._handle_response(message) + + def _schedule(self, coroutine: Awaitable[Any]) -> None: + task = asyncio.create_task(coroutine) + self._inflight.add(task) + task.add_done_callback(self._task_done) + + def _task_done(self, task: asyncio.Task[Any]) -> None: + self._inflight.discard(task) + if task.cancelled(): + return + with contextlib.suppress(Exception): + task.result() + + async def _handle_request(self, message: dict[str, Any]) -> None: + payload: dict[str, Any] = {"jsonrpc": "2.0", "id": message["id"]} + try: + result = await self._handler(message["method"], message.get("params"), False) + if isinstance(result, BaseModel): + result = result.model_dump() + payload["result"] = result if result is not None else None + except RequestError as exc: + payload["error"] = exc.to_error_obj() + except ValidationError as exc: + payload["error"] = RequestError.invalid_params({"errors": exc.errors()}).to_error_obj() + except Exception as exc: + try: + data = json.loads(str(exc)) + except Exception: + data = {"details": str(exc)} + payload["error"] = RequestError.internal_error(data).to_error_obj() + await self._send_obj(payload) + + async def _handle_notification(self, message: dict[str, Any]) -> None: + with contextlib.suppress(Exception): + await self._handler(message["method"], message.get("params"), True) + + async def _handle_response(self, message: dict[str, Any]) -> None: + pending = self._pending.pop(message["id"], None) + if pending is None: + return + if "result" in message: + pending.future.set_result(message.get("result")) + return + if "error" in message: + error_obj = message.get("error") or {} + pending.future.set_exception( + RequestError( + error_obj.get("code", -32603), + error_obj.get("message", "Error"), + error_obj.get("data"), + ) + ) + return + pending.future.set_result(None) + + async def _send_obj(self, payload: dict[str, Any]) -> None: + data = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8") + async with self._write_lock: + self._writer.write(data) + with contextlib.suppress(ConnectionError, RuntimeError): + await self._writer.drain() diff --git a/src/acp/core.py b/src/acp/core.py index 6a67d44..8afa468 100644 --- a/src/acp/core.py +++ b/src/acp/core.py @@ -1,672 +1,27 @@ -from __future__ import annotations - -import asyncio -import contextlib -import json -import logging -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any, Protocol - -from pydantic import BaseModel, ValidationError - -from .meta import AGENT_METHODS, CLIENT_METHODS, PROTOCOL_VERSION # noqa: F401 -from .schema import ( - AuthenticateRequest, - AuthenticateResponse, - CancelNotification, - CreateTerminalRequest, - CreateTerminalResponse, - InitializeRequest, - InitializeResponse, - KillTerminalCommandRequest, - KillTerminalCommandResponse, - LoadSessionRequest, - LoadSessionResponse, - NewSessionRequest, - NewSessionResponse, - PromptRequest, - PromptResponse, - ReadTextFileRequest, - ReadTextFileResponse, - ReleaseTerminalRequest, - ReleaseTerminalResponse, - RequestPermissionRequest, - RequestPermissionResponse, - SessionNotification, - SetSessionModelRequest, - SetSessionModelResponse, - SetSessionModeRequest, - SetSessionModeResponse, - TerminalOutputRequest, - TerminalOutputResponse, - WaitForTerminalExitRequest, - WaitForTerminalExitResponse, - WriteTextFileRequest, - WriteTextFileResponse, -) - -JsonValue = Any -MethodHandler = Callable[[str, JsonValue | None, bool], Awaitable[JsonValue | None]] - -_AGENT_CONNECTION_ERROR = "AgentSideConnection requires asyncio StreamWriter/StreamReader" -_CLIENT_CONNECTION_ERROR = "ClientSideConnection requires asyncio StreamWriter/StreamReader" - - -class RequestError(Exception): - """JSON-RPC 2.0 error helper.""" - - def __init__(self, code: int, message: str, data: Any | None = None) -> None: - super().__init__(message) - self.code = code - self.data = data - - @staticmethod - def parse_error(data: dict | None = None) -> RequestError: - return RequestError(-32700, "Parse error", data) - - @staticmethod - def invalid_request(data: dict | None = None) -> RequestError: - return RequestError(-32600, "Invalid request", data) - - @staticmethod - def method_not_found(method: str) -> RequestError: - return RequestError(-32601, "Method not found", {"method": method}) - - @staticmethod - def invalid_params(data: dict | None = None) -> RequestError: - return RequestError(-32602, "Invalid params", data) - - @staticmethod - def internal_error(data: dict | None = None) -> RequestError: - return RequestError(-32603, "Internal error", data) - - @staticmethod - def auth_required(data: dict | None = None) -> RequestError: - return RequestError(-32000, "Authentication required", data) - - @staticmethod - def resource_not_found(uri: str | None = None) -> RequestError: - data = {"uri": uri} if uri is not None else None - return RequestError(-32002, "Resource not found", data) - - def to_error_obj(self) -> dict[str, Any]: - return {"code": self.code, "message": str(self), "data": self.data} - - -class _NoMatch: - """Sentinel returned by routing helpers when no handler matches.""" - - -_NO_MATCH = _NoMatch() - - -@dataclass(slots=True) -class _Pending: - future: asyncio.Future[Any] - - -def _dump_params(params: BaseModel) -> dict[str, Any]: - return params.model_dump(exclude_none=True, exclude_defaults=True) - - -def _optional_result(payload: Any) -> dict[str, Any]: - if payload is None: - return {} - if isinstance(payload, BaseModel): - return _dump_params(payload) - return payload - - -class Connection: - """Minimal JSON-RPC 2.0 connection over newline-delimited JSON frames.""" - - def __init__( - self, - handler: MethodHandler, - writer: asyncio.StreamWriter, - reader: asyncio.StreamReader, - ) -> None: - self._handler = handler - self._writer = writer - self._reader = reader - self._next_request_id = 0 - self._pending: dict[int, _Pending] = {} - self._inflight: set[asyncio.Task[Any]] = set() - self._write_lock = asyncio.Lock() - self._recv_task = asyncio.create_task(self._receive_loop()) - - async def close(self) -> None: - """Stop the receive loop and cancel any in-flight handler tasks.""" - if not self._recv_task.done(): - self._recv_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._recv_task - if self._inflight: - tasks = list(self._inflight) - for task in tasks: - task.cancel() - for task in tasks: - with contextlib.suppress(asyncio.CancelledError): - await task - - async def send_request(self, method: str, params: JsonValue | None = None) -> Any: - request_id = self._next_request_id - self._next_request_id += 1 - future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() - self._pending[request_id] = _Pending(future) - payload = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} - await self._send_obj(payload) - return await future - - async def send_notification(self, method: str, params: JsonValue | None = None) -> None: - payload = {"jsonrpc": "2.0", "method": method, "params": params} - await self._send_obj(payload) - - async def _receive_loop(self) -> None: - try: - while True: - line = await self._reader.readline() - if not line: - break - try: - message: dict[str, Any] = json.loads(line) - except Exception: - logging.exception("Error parsing JSON-RPC message") - continue - await self._process_message(message) - except asyncio.CancelledError: - return - - async def _process_message(self, message: dict[str, Any]) -> None: - method = message.get("method") - has_id = "id" in message - if method is not None and has_id: - self._schedule(self._handle_request(message)) - return - if method is not None and not has_id: - await self._handle_notification(message) - return - if has_id: - await self._handle_response(message) - - def _schedule(self, coroutine: Awaitable[Any]) -> None: - task = asyncio.create_task(coroutine) - self._inflight.add(task) - task.add_done_callback(self._task_done) - - def _task_done(self, task: asyncio.Task[Any]) -> None: - self._inflight.discard(task) - if task.cancelled(): - return - with contextlib.suppress(Exception): - task.result() - - async def _handle_request(self, message: dict[str, Any]) -> None: - payload: dict[str, Any] = {"jsonrpc": "2.0", "id": message["id"]} - try: - result = await self._handler(message["method"], message.get("params"), False) - if isinstance(result, BaseModel): - result = result.model_dump() - payload["result"] = result if result is not None else None - except RequestError as exc: - payload["error"] = exc.to_error_obj() - except ValidationError as exc: - payload["error"] = RequestError.invalid_params({"errors": exc.errors()}).to_error_obj() - except Exception as exc: - try: - data = json.loads(str(exc)) - except Exception: - data = {"details": str(exc)} - payload["error"] = RequestError.internal_error(data).to_error_obj() - await self._send_obj(payload) - - async def _handle_notification(self, message: dict[str, Any]) -> None: - with contextlib.suppress(Exception): - await self._handler(message["method"], message.get("params"), True) - - async def _handle_response(self, message: dict[str, Any]) -> None: - pending = self._pending.pop(message["id"], None) - if pending is None: - return - if "result" in message: - pending.future.set_result(message.get("result")) - return - if "error" in message: - error_obj = message.get("error") or {} - pending.future.set_exception( - RequestError( - error_obj.get("code", -32603), - error_obj.get("message", "Error"), - error_obj.get("data"), - ) - ) - return - pending.future.set_result(None) - - async def _send_obj(self, payload: dict[str, Any]) -> None: - data = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8") - async with self._write_lock: - self._writer.write(data) - with contextlib.suppress(ConnectionError, RuntimeError): - await self._writer.drain() - - -class Client(Protocol): - async def requestPermission(self, params: RequestPermissionRequest) -> RequestPermissionResponse: ... - - async def sessionUpdate(self, params: SessionNotification) -> None: ... - - async def writeTextFile(self, params: WriteTextFileRequest) -> WriteTextFileResponse | None: ... - - async def readTextFile(self, params: ReadTextFileRequest) -> ReadTextFileResponse: ... - - async def createTerminal(self, params: CreateTerminalRequest) -> CreateTerminalResponse: ... - - async def terminalOutput(self, params: TerminalOutputRequest) -> TerminalOutputResponse: ... - - async def releaseTerminal(self, params: ReleaseTerminalRequest) -> ReleaseTerminalResponse | None: ... - - async def waitForTerminalExit(self, params: WaitForTerminalExitRequest) -> WaitForTerminalExitResponse: ... - - async def killTerminal(self, params: KillTerminalCommandRequest) -> KillTerminalCommandResponse | None: ... - - async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: ... - - async def extNotification(self, method: str, params: dict[str, Any]) -> None: ... - - -class Agent(Protocol): - async def initialize(self, params: InitializeRequest) -> InitializeResponse: ... - - async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: ... - - async def loadSession(self, params: LoadSessionRequest) -> LoadSessionResponse | None: ... - - async def setSessionMode(self, params: SetSessionModeRequest) -> SetSessionModeResponse | None: ... - - async def setSessionModel(self, params: SetSessionModelRequest) -> SetSessionModelResponse | None: ... - - async def authenticate(self, params: AuthenticateRequest) -> AuthenticateResponse | None: ... - - async def prompt(self, params: PromptRequest) -> PromptResponse: ... +"""Compatibility re-exports for historical imports. - async def cancel(self, params: CancelNotification) -> None: ... +The project now keeps implementation in dedicated modules mirroring the +agent-client-protocol Rust structure, but external callers may still import +from ``acp.core``. Keep the surface API stable by forwarding to the new homes. +""" - async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: ... - - async def extNotification(self, method: str, params: dict[str, Any]) -> None: ... - - -class AgentSideConnection: - """Agent-side connection wrapper that dispatches JSON-RPC messages to a Client implementation.""" - - def __init__( - self, - to_agent: Callable[[AgentSideConnection], Agent], - input_stream: Any, - output_stream: Any, - ) -> None: - agent = to_agent(self) - handler = _create_agent_handler(agent) - - if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance(output_stream, asyncio.StreamReader): - raise TypeError(_AGENT_CONNECTION_ERROR) - self._conn = Connection(handler, input_stream, output_stream) - - async def sessionUpdate(self, params: SessionNotification) -> None: - await self._conn.send_notification( - CLIENT_METHODS["session_update"], - _dump_params(params), - ) - - async def requestPermission(self, params: RequestPermissionRequest) -> RequestPermissionResponse: - response = await self._conn.send_request( - CLIENT_METHODS["session_request_permission"], - _dump_params(params), - ) - return RequestPermissionResponse.model_validate(response) - - async def readTextFile(self, params: ReadTextFileRequest) -> ReadTextFileResponse: - response = await self._conn.send_request( - CLIENT_METHODS["fs_read_text_file"], - _dump_params(params), - ) - return ReadTextFileResponse.model_validate(response) - - async def writeTextFile(self, params: WriteTextFileRequest) -> WriteTextFileResponse | None: - response = await self._conn.send_request( - CLIENT_METHODS["fs_write_text_file"], - _dump_params(params), - ) - return WriteTextFileResponse.model_validate(response) if isinstance(response, dict) else None - - async def createTerminal(self, params: CreateTerminalRequest) -> TerminalHandle: - response = await self._conn.send_request( - CLIENT_METHODS["terminal_create"], - _dump_params(params), - ) - create_response = CreateTerminalResponse.model_validate(response) - return TerminalHandle(create_response.terminalId, params.sessionId, self._conn) - - async def terminalOutput(self, params: TerminalOutputRequest) -> TerminalOutputResponse: - response = await self._conn.send_request( - CLIENT_METHODS["terminal_output"], - _dump_params(params), - ) - return TerminalOutputResponse.model_validate(response) - - async def releaseTerminal(self, params: ReleaseTerminalRequest) -> ReleaseTerminalResponse | None: - response = await self._conn.send_request( - CLIENT_METHODS["terminal_release"], - _dump_params(params), - ) - return ReleaseTerminalResponse.model_validate(response) if isinstance(response, dict) else None - - async def waitForTerminalExit(self, params: WaitForTerminalExitRequest) -> WaitForTerminalExitResponse: - response = await self._conn.send_request( - CLIENT_METHODS["terminal_wait_for_exit"], - _dump_params(params), - ) - return WaitForTerminalExitResponse.model_validate(response) - - async def killTerminal(self, params: KillTerminalCommandRequest) -> KillTerminalCommandResponse | None: - response = await self._conn.send_request( - CLIENT_METHODS["terminal_kill"], - _dump_params(params), - ) - return KillTerminalCommandResponse.model_validate(response) if isinstance(response, dict) else None - - async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: - return await self._conn.send_request(f"_{method}", params) - - async def extNotification(self, method: str, params: dict[str, Any]) -> None: - await self._conn.send_notification(f"_{method}", params) - - -class ClientSideConnection: - """Client-side connection wrapper that dispatches JSON-RPC messages to an Agent implementation.""" - - def __init__( - self, - to_client: Callable[[Agent], Client], - input_stream: Any, - output_stream: Any, - ) -> None: - if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance(output_stream, asyncio.StreamReader): - raise TypeError(_CLIENT_CONNECTION_ERROR) - - client = to_client(self) # type: ignore[arg-type] - handler = _create_client_handler(client) - self._conn = Connection(handler, input_stream, output_stream) - - async def initialize(self, params: InitializeRequest) -> InitializeResponse: - response = await self._conn.send_request( - AGENT_METHODS["initialize"], - _dump_params(params), - ) - return InitializeResponse.model_validate(response) - - async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: - response = await self._conn.send_request( - AGENT_METHODS["session_new"], - _dump_params(params), - ) - return NewSessionResponse.model_validate(response) - - async def loadSession(self, params: LoadSessionRequest) -> LoadSessionResponse: - response = await self._conn.send_request( - AGENT_METHODS["session_load"], - _dump_params(params), - ) - payload = response if isinstance(response, dict) else {} - return LoadSessionResponse.model_validate(payload) - - async def setSessionMode(self, params: SetSessionModeRequest) -> SetSessionModeResponse: - response = await self._conn.send_request( - AGENT_METHODS["session_set_mode"], - _dump_params(params), - ) - payload = response if isinstance(response, dict) else {} - return SetSessionModeResponse.model_validate(payload) - - async def setSessionModel(self, params: SetSessionModelRequest) -> SetSessionModelResponse: - response = await self._conn.send_request( - AGENT_METHODS["session_set_model"], - _dump_params(params), - ) - payload = response if isinstance(response, dict) else {} - return SetSessionModelResponse.model_validate(payload) - - async def authenticate(self, params: AuthenticateRequest) -> AuthenticateResponse: - response = await self._conn.send_request( - AGENT_METHODS["authenticate"], - _dump_params(params), - ) - payload = response if isinstance(response, dict) else {} - return AuthenticateResponse.model_validate(payload) - - async def prompt(self, params: PromptRequest) -> PromptResponse: - response = await self._conn.send_request( - AGENT_METHODS["session_prompt"], - _dump_params(params), - ) - return PromptResponse.model_validate(response) - - async def cancel(self, params: CancelNotification) -> None: - await self._conn.send_notification( - AGENT_METHODS["session_cancel"], - _dump_params(params), - ) - - async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: - return await self._conn.send_request(f"_{method}", params) - - async def extNotification(self, method: str, params: dict[str, Any]) -> None: - await self._conn.send_notification(f"_{method}", params) - - -class TerminalHandle: - def __init__(self, terminal_id: str, session_id: str, conn: Connection) -> None: - self.id = terminal_id - self._session_id = session_id - self._conn = conn - - async def current_output(self) -> TerminalOutputResponse: - response = await self._conn.send_request( - CLIENT_METHODS["terminal_output"], - {"sessionId": self._session_id, "terminalId": self.id}, - ) - return TerminalOutputResponse.model_validate(response) - - async def wait_for_exit(self) -> WaitForTerminalExitResponse: - response = await self._conn.send_request( - CLIENT_METHODS["terminal_wait_for_exit"], - {"sessionId": self._session_id, "terminalId": self.id}, - ) - return WaitForTerminalExitResponse.model_validate(response) - - async def kill(self) -> KillTerminalCommandResponse: - response = await self._conn.send_request( - CLIENT_METHODS["terminal_kill"], - {"sessionId": self._session_id, "terminalId": self.id}, - ) - payload = response if isinstance(response, dict) else {} - return KillTerminalCommandResponse.model_validate(payload) - - async def release(self) -> ReleaseTerminalResponse: - response = await self._conn.send_request( - CLIENT_METHODS["terminal_release"], - {"sessionId": self._session_id, "terminalId": self.id}, - ) - payload = response if isinstance(response, dict) else {} - return ReleaseTerminalResponse.model_validate(payload) - - -async def _handle_agent_init_methods(agent: Agent, method: str, params: Any | None) -> Any: - if method == AGENT_METHODS["initialize"]: - request = InitializeRequest.model_validate(params) - return await agent.initialize(request) - if method == AGENT_METHODS["session_new"]: - request = NewSessionRequest.model_validate(params) - return await agent.newSession(request) - return _NO_MATCH - - -async def _handle_agent_session_methods(agent: Agent, method: str, params: Any | None) -> Any: - if method == AGENT_METHODS["session_load"]: - if not hasattr(agent, "loadSession"): - raise RequestError.method_not_found(method) - request = LoadSessionRequest.model_validate(params) - result = await agent.loadSession(request) - return _optional_result(result) - if method == AGENT_METHODS["session_set_mode"]: - if not hasattr(agent, "setSessionMode"): - raise RequestError.method_not_found(method) - request = SetSessionModeRequest.model_validate(params) - result = await agent.setSessionMode(request) - return _optional_result(result) - if method == AGENT_METHODS["session_prompt"]: - request = PromptRequest.model_validate(params) - return await agent.prompt(request) - if method == AGENT_METHODS["session_set_model"]: - if not hasattr(agent, "setSessionModel"): - raise RequestError.method_not_found(method) - request = SetSessionModelRequest.model_validate(params) - result = await agent.setSessionModel(request) - return _optional_result(result) - if method == AGENT_METHODS["session_cancel"]: - request = CancelNotification.model_validate(params) - return await agent.cancel(request) - return _NO_MATCH - - -async def _handle_agent_auth_methods(agent: Agent, method: str, params: Any | None) -> Any: - if method == AGENT_METHODS["authenticate"]: - if not hasattr(agent, "authenticate"): - raise RequestError.method_not_found(method) - request = AuthenticateRequest.model_validate(params) - result = await agent.authenticate(request) - return _optional_result(result) - return _NO_MATCH - - -async def _handle_agent_extension_methods(agent: Agent, method: str, params: Any | None, is_notification: bool) -> Any: - if isinstance(method, str) and method.startswith("_"): - ext_name = method[1:] - if is_notification: - if hasattr(agent, "extNotification"): - await agent.extNotification(ext_name, params or {}) # type: ignore[arg-type] - return None - return None - if hasattr(agent, "extMethod"): - return await agent.extMethod(ext_name, params or {}) # type: ignore[arg-type] - return _NO_MATCH - return _NO_MATCH - - -async def _handle_agent_method(agent: Agent, method: str, params: Any | None, is_notification: bool) -> Any: - for resolver in ( - _handle_agent_init_methods, - _handle_agent_session_methods, - _handle_agent_auth_methods, - ): - result = await resolver(agent, method, params) - if result is not _NO_MATCH: - return result - ext_result = await _handle_agent_extension_methods(agent, method, params, is_notification) - if ext_result is not _NO_MATCH: - return ext_result - raise RequestError.method_not_found(method) - - -def _create_agent_handler(agent: Agent) -> MethodHandler: - async def handler(method: str, params: Any | None, is_notification: bool) -> Any: - return await _handle_agent_method(agent, method, params, is_notification) - - return handler - - -async def _handle_client_core_methods(client: Client, method: str, params: Any | None) -> Any: - if method == CLIENT_METHODS["fs_write_text_file"]: - request = WriteTextFileRequest.model_validate(params) - return await client.writeTextFile(request) - if method == CLIENT_METHODS["fs_read_text_file"]: - request = ReadTextFileRequest.model_validate(params) - return await client.readTextFile(request) - if method == CLIENT_METHODS["session_request_permission"]: - request = RequestPermissionRequest.model_validate(params) - return await client.requestPermission(request) - if method == CLIENT_METHODS["session_update"]: - notification = SessionNotification.model_validate(params) - await client.sessionUpdate(notification) - return None - return _NO_MATCH - - -async def _handle_client_terminal_methods(client: Client, method: str, params: Any | None) -> Any: # noqa: C901 - if method == CLIENT_METHODS["terminal_create"]: - if not hasattr(client, "createTerminal"): - return None - request = CreateTerminalRequest.model_validate(params) - return await client.createTerminal(request) - if method == CLIENT_METHODS["terminal_output"]: - if not hasattr(client, "terminalOutput"): - return None - request = TerminalOutputRequest.model_validate(params) - return await client.terminalOutput(request) - if method == CLIENT_METHODS["terminal_release"]: - if not hasattr(client, "releaseTerminal"): - return {} - request = ReleaseTerminalRequest.model_validate(params) - result = await client.releaseTerminal(request) - return _optional_result(result) - if method == CLIENT_METHODS["terminal_wait_for_exit"]: - if not hasattr(client, "waitForTerminalExit"): - return None - request = WaitForTerminalExitRequest.model_validate(params) - return await client.waitForTerminalExit(request) - if method == CLIENT_METHODS["terminal_kill"]: - if not hasattr(client, "killTerminal"): - return {} - request = KillTerminalCommandRequest.model_validate(params) - result = await client.killTerminal(request) - return _optional_result(result) - return _NO_MATCH - - -async def _handle_client_extension_methods( - client: Client, method: str, params: Any | None, is_notification: bool -) -> Any: - if isinstance(method, str) and method.startswith("_"): - ext_name = method[1:] - if is_notification: - if hasattr(client, "extNotification"): - await client.extNotification(ext_name, params or {}) # type: ignore[arg-type] - return None - return None - if hasattr(client, "extMethod"): - return await client.extMethod(ext_name, params or {}) # type: ignore[arg-type] - return _NO_MATCH - return _NO_MATCH - - -async def _handle_client_method(client: Client, method: str, params: Any | None, is_notification: bool) -> Any: - for resolver in ( - _handle_client_core_methods, - _handle_client_terminal_methods, - ): - result = await resolver(client, method, params) - if result is not _NO_MATCH: - return result - ext_result = await _handle_client_extension_methods(client, method, params, is_notification) - if ext_result is not _NO_MATCH: - return ext_result - raise RequestError.method_not_found(method) - - -def _create_client_handler(client: Client) -> MethodHandler: - async def handler(method: str, params: Any | None, is_notification: bool) -> Any: - return await _handle_client_method(client, method, params, is_notification) +from __future__ import annotations - return handler +from .agent.connection import AgentSideConnection +from .client.connection import ClientSideConnection +from .connection import Connection, JsonValue, MethodHandler +from .exceptions import RequestError +from .interfaces import Agent, Client +from .terminal import TerminalHandle + +__all__ = [ + "Agent", + "AgentSideConnection", + "Client", + "ClientSideConnection", + "Connection", + "JsonValue", + "MethodHandler", + "RequestError", + "TerminalHandle", +] diff --git a/src/acp/exceptions.py b/src/acp/exceptions.py new file mode 100644 index 0000000..c9a4dd9 --- /dev/null +++ b/src/acp/exceptions.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Any + +__all__ = ["RequestError"] + + +class RequestError(Exception): + """JSON-RPC 2.0 error helper.""" + + def __init__(self, code: int, message: str, data: Any | None = None) -> None: + super().__init__(message) + self.code = code + self.data = data + + @staticmethod + def parse_error(data: dict | None = None) -> RequestError: + return RequestError(-32700, "Parse error", data) + + @staticmethod + def invalid_request(data: dict | None = None) -> RequestError: + return RequestError(-32600, "Invalid request", data) + + @staticmethod + def method_not_found(method: str) -> RequestError: + return RequestError(-32601, "Method not found", {"method": method}) + + @staticmethod + def invalid_params(data: dict | None = None) -> RequestError: + return RequestError(-32602, "Invalid params", data) + + @staticmethod + def internal_error(data: dict | None = None) -> RequestError: + return RequestError(-32603, "Internal error", data) + + @staticmethod + def auth_required(data: dict | None = None) -> RequestError: + return RequestError(-32000, "Authentication required", data) + + @staticmethod + def resource_not_found(uri: str | None = None) -> RequestError: + data = {"uri": uri} if uri is not None else None + return RequestError(-32002, "Resource not found", data) + + def to_error_obj(self) -> dict[str, Any]: + return {"code": self.code, "message": str(self), "data": self.data} diff --git a/src/acp/interfaces.py b/src/acp/interfaces.py new file mode 100644 index 0000000..11d04d3 --- /dev/null +++ b/src/acp/interfaces.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import Any, Protocol + +from .schema import ( + AuthenticateRequest, + AuthenticateResponse, + CancelNotification, + CreateTerminalRequest, + CreateTerminalResponse, + InitializeRequest, + InitializeResponse, + KillTerminalCommandRequest, + KillTerminalCommandResponse, + LoadSessionRequest, + LoadSessionResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + ReadTextFileRequest, + ReadTextFileResponse, + ReleaseTerminalRequest, + ReleaseTerminalResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + SetSessionModelRequest, + SetSessionModelResponse, + SetSessionModeRequest, + SetSessionModeResponse, + TerminalOutputRequest, + TerminalOutputResponse, + WaitForTerminalExitRequest, + WaitForTerminalExitResponse, + WriteTextFileRequest, + WriteTextFileResponse, +) + +__all__ = ["Agent", "Client"] + + +class Client(Protocol): + async def requestPermission(self, params: RequestPermissionRequest) -> RequestPermissionResponse: ... + + async def sessionUpdate(self, params: SessionNotification) -> None: ... + + async def writeTextFile(self, params: WriteTextFileRequest) -> WriteTextFileResponse | None: ... + + async def readTextFile(self, params: ReadTextFileRequest) -> ReadTextFileResponse: ... + + async def createTerminal(self, params: CreateTerminalRequest) -> CreateTerminalResponse: ... + + async def terminalOutput(self, params: TerminalOutputRequest) -> TerminalOutputResponse: ... + + async def releaseTerminal(self, params: ReleaseTerminalRequest) -> ReleaseTerminalResponse | None: ... + + async def waitForTerminalExit(self, params: WaitForTerminalExitRequest) -> WaitForTerminalExitResponse: ... + + async def killTerminal(self, params: KillTerminalCommandRequest) -> KillTerminalCommandResponse | None: ... + + async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: ... + + async def extNotification(self, method: str, params: dict[str, Any]) -> None: ... + + +class Agent(Protocol): + async def initialize(self, params: InitializeRequest) -> InitializeResponse: ... + + async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: ... + + async def loadSession(self, params: LoadSessionRequest) -> LoadSessionResponse | None: ... + + async def setSessionMode(self, params: SetSessionModeRequest) -> SetSessionModeResponse | None: ... + + async def setSessionModel(self, params: SetSessionModelRequest) -> SetSessionModelResponse | None: ... + + async def authenticate(self, params: AuthenticateRequest) -> AuthenticateResponse | None: ... + + async def prompt(self, params: PromptRequest) -> PromptResponse: ... + + async def cancel(self, params: CancelNotification) -> None: ... + + async def extMethod(self, method: str, params: dict[str, Any]) -> dict[str, Any]: ... + + async def extNotification(self, method: str, params: dict[str, Any]) -> None: ... diff --git a/src/acp/terminal.py b/src/acp/terminal.py new file mode 100644 index 0000000..698611e --- /dev/null +++ b/src/acp/terminal.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from .connection import Connection +from .meta import CLIENT_METHODS +from .schema import ( + KillTerminalCommandResponse, + ReleaseTerminalResponse, + TerminalOutputResponse, + WaitForTerminalExitResponse, +) + +__all__ = ["TerminalHandle"] + + +class TerminalHandle: + def __init__(self, terminal_id: str, session_id: str, conn: Connection) -> None: + self.id = terminal_id + self._session_id = session_id + self._conn = conn + + async def current_output(self) -> TerminalOutputResponse: + response = await self._conn.send_request( + CLIENT_METHODS["terminal_output"], + {"sessionId": self._session_id, "terminalId": self.id}, + ) + return TerminalOutputResponse.model_validate(response) + + async def wait_for_exit(self) -> WaitForTerminalExitResponse: + response = await self._conn.send_request( + CLIENT_METHODS["terminal_wait_for_exit"], + {"sessionId": self._session_id, "terminalId": self.id}, + ) + return WaitForTerminalExitResponse.model_validate(response) + + async def kill(self) -> KillTerminalCommandResponse: + response = await self._conn.send_request( + CLIENT_METHODS["terminal_kill"], + {"sessionId": self._session_id, "terminalId": self.id}, + ) + payload = response if isinstance(response, dict) else {} + return KillTerminalCommandResponse.model_validate(payload) + + async def release(self) -> ReleaseTerminalResponse: + response = await self._conn.send_request( + CLIENT_METHODS["terminal_release"], + {"sessionId": self._session_id, "terminalId": self.id}, + ) + payload = response if isinstance(response, dict) else {} + return ReleaseTerminalResponse.model_validate(payload) diff --git a/src/acp/utils.py b/src/acp/utils.py new file mode 100644 index 0000000..b84a5a7 --- /dev/null +++ b/src/acp/utils.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Any, TypeVar + +from pydantic import BaseModel + +from .connection import Connection + +__all__ = [ + "ensure_dict", + "normalize_result", + "notify_model", + "request_model", + "request_model_from_dict", + "request_optional_model", + "serialize_params", + "validate_model", + "validate_model_from_dict", + "validate_optional_model", +] + +ModelT = TypeVar("ModelT", bound=BaseModel) + + +def serialize_params(params: BaseModel) -> dict[str, Any]: + """Return a JSON-serializable representation used for RPC calls.""" + return params.model_dump(exclude_none=True, exclude_defaults=True) + + +def normalize_result(payload: Any) -> dict[str, Any]: + """Convert optional BaseModel/None responses into JSON-friendly payloads.""" + if payload is None: + return {} + if isinstance(payload, BaseModel): + return serialize_params(payload) + return payload + + +def ensure_dict(payload: Any) -> dict[str, Any]: + """Return payload when it is a dict, otherwise an empty dict.""" + return payload if isinstance(payload, dict) else {} + + +def validate_model(payload: Any, model_type: type[ModelT]) -> ModelT: + """Validate payload using the provided Pydantic model.""" + return model_type.model_validate(payload) + + +def validate_model_from_dict(payload: Any, model_type: type[ModelT]) -> ModelT: + """Validate payload, coercing non-dict values to an empty dict first.""" + return model_type.model_validate(ensure_dict(payload)) + + +def validate_optional_model(payload: Any, model_type: type[ModelT]) -> ModelT | None: + """Validate payload when it is a dict, otherwise return None.""" + if isinstance(payload, dict): + return model_type.model_validate(payload) + return None + + +async def request_model( + conn: Connection, + method: str, + params: BaseModel, + response_model: type[ModelT], +) -> ModelT: + """Send a request with serialized params and validate the response.""" + response = await conn.send_request(method, serialize_params(params)) + return validate_model(response, response_model) + + +async def request_model_from_dict( + conn: Connection, + method: str, + params: BaseModel, + response_model: type[ModelT], +) -> ModelT: + """Send a request and validate the response, coercing non-dict payloads.""" + response = await conn.send_request(method, serialize_params(params)) + return validate_model_from_dict(response, response_model) + + +async def request_optional_model( + conn: Connection, + method: str, + params: BaseModel, + response_model: type[ModelT], +) -> ModelT | None: + """Send a request and validate optional dict responses.""" + response = await conn.send_request(method, serialize_params(params)) + return validate_optional_model(response, response_model) + + +async def notify_model(conn: Connection, method: str, params: BaseModel) -> None: + """Send a notification with serialized params.""" + await conn.send_notification(method, serialize_params(params)) From fa007f9772c43a8b06c8139689acde5c93102f37 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 11 Oct 2025 04:34:17 +0800 Subject: [PATCH 3/6] feat: intro task module for better handle Signed-off-by: Chojan Shang --- src/acp/connection.py | 165 ++++++++++++++++++++++++------------- src/acp/task/__init__.py | 44 ++++++++++ src/acp/task/dispatcher.py | 94 +++++++++++++++++++++ src/acp/task/queue.py | 67 +++++++++++++++ src/acp/task/sender.py | 67 +++++++++++++++ src/acp/task/state.py | 84 +++++++++++++++++++ src/acp/task/supervisor.py | 80 ++++++++++++++++++ 7 files changed, 545 insertions(+), 56 deletions(-) create mode 100644 src/acp/task/__init__.py create mode 100644 src/acp/task/dispatcher.py create mode 100644 src/acp/task/queue.py create mode 100644 src/acp/task/sender.py create mode 100644 src/acp/task/state.py create mode 100644 src/acp/task/supervisor.py diff --git a/src/acp/connection.py b/src/acp/connection.py index 51be72b..951b5e4 100644 --- a/src/acp/connection.py +++ b/src/acp/connection.py @@ -5,12 +5,26 @@ import json import logging from collections.abc import Awaitable, Callable -from dataclasses import dataclass from typing import Any from pydantic import BaseModel, ValidationError from .exceptions import RequestError +from .task import ( + DefaultMessageDispatcher, + InMemoryMessageQueue, + InMemoryMessageStateStore, + MessageDispatcher, + MessageQueue, + MessageSender, + MessageStateStore, + NotificationRunner, + RequestRunner, + RpcTask, + RpcTaskKind, + SenderFactory, + TaskSupervisor, +) JsonValue = Any MethodHandler = Callable[[str, JsonValue | None, bool], Awaitable[JsonValue | None]] @@ -19,9 +33,10 @@ __all__ = ["Connection", "JsonValue", "MethodHandler"] -@dataclass(slots=True) -class _Pending: - future: asyncio.Future[Any] +DispatcherFactory = Callable[ + [MessageQueue, TaskSupervisor, MessageStateStore, RequestRunner, NotificationRunner], + MessageDispatcher, +] class Connection: @@ -32,42 +47,64 @@ def __init__( handler: MethodHandler, writer: asyncio.StreamWriter, reader: asyncio.StreamReader, + *, + queue: MessageQueue | None = None, + state_store: MessageStateStore | None = None, + dispatcher_factory: DispatcherFactory | None = None, + sender_factory: SenderFactory | None = None, ) -> None: self._handler = handler self._writer = writer self._reader = reader self._next_request_id = 0 - self._pending: dict[int, _Pending] = {} - self._inflight: set[asyncio.Task[Any]] = set() - self._write_lock = asyncio.Lock() - self._recv_task = asyncio.create_task(self._receive_loop()) + self._state = state_store or InMemoryMessageStateStore() + self._tasks = TaskSupervisor(source="acp.Connection") + self._tasks.add_error_handler(self._on_task_error) + self._queue = queue or InMemoryMessageQueue() + self._closed = False + self._sender = (sender_factory or self._default_sender_factory)(self._writer, self._tasks) + self._recv_task = self._tasks.create( + self._receive_loop(), + name="acp.Connection.receive", + on_error=self._on_receive_error, + ) + dispatcher_factory = dispatcher_factory or self._default_dispatcher_factory + self._dispatcher = dispatcher_factory( + self._queue, + self._tasks, + self._state, + self._run_request, + self._run_notification, + ) + self._dispatcher.start() async def close(self) -> None: """Stop the receive loop and cancel any in-flight handler tasks.""" - if not self._recv_task.done(): - self._recv_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._recv_task - if self._inflight: - tasks = list(self._inflight) - for task in tasks: - task.cancel() - for task in tasks: - with contextlib.suppress(asyncio.CancelledError): - await task + if self._closed: + return + self._closed = True + await self._dispatcher.stop() + await self._sender.close() + await self._tasks.shutdown() + self._state.reject_all_outgoing(ConnectionError("Connection closed")) + + async def __aenter__(self) -> Connection: + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.close() async def send_request(self, method: str, params: JsonValue | None = None) -> Any: request_id = self._next_request_id self._next_request_id += 1 - future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() - self._pending[request_id] = _Pending(future) + future = self._state.register_outgoing(request_id, method) payload = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} - await self._send_obj(payload) + await self._sender.send(payload) return await future async def send_notification(self, method: str, params: JsonValue | None = None) -> None: payload = {"jsonrpc": "2.0", "method": method, "params": params} - await self._send_obj(payload) + await self._sender.send(payload) async def _receive_loop(self) -> None: try: @@ -88,71 +125,87 @@ async def _process_message(self, message: dict[str, Any]) -> None: method = message.get("method") has_id = "id" in message if method is not None and has_id: - self._schedule(self._handle_request(message)) + await self._queue.publish(RpcTask(RpcTaskKind.REQUEST, message)) return if method is not None and not has_id: - await self._handle_notification(message) + await self._queue.publish(RpcTask(RpcTaskKind.NOTIFICATION, message)) return if has_id: await self._handle_response(message) - def _schedule(self, coroutine: Awaitable[Any]) -> None: - task = asyncio.create_task(coroutine) - self._inflight.add(task) - task.add_done_callback(self._task_done) - - def _task_done(self, task: asyncio.Task[Any]) -> None: - self._inflight.discard(task) - if task.cancelled(): - return - with contextlib.suppress(Exception): - task.result() - - async def _handle_request(self, message: dict[str, Any]) -> None: + async def _run_request(self, message: dict[str, Any]) -> Any: payload: dict[str, Any] = {"jsonrpc": "2.0", "id": message["id"]} try: result = await self._handler(message["method"], message.get("params"), False) if isinstance(result, BaseModel): result = result.model_dump() payload["result"] = result if result is not None else None + await self._sender.send(payload) + return payload.get("result") except RequestError as exc: payload["error"] = exc.to_error_obj() + await self._sender.send(payload) + raise except ValidationError as exc: - payload["error"] = RequestError.invalid_params({"errors": exc.errors()}).to_error_obj() + err = RequestError.invalid_params({"errors": exc.errors()}) + payload["error"] = err.to_error_obj() + await self._sender.send(payload) + raise err from None except Exception as exc: try: data = json.loads(str(exc)) except Exception: data = {"details": str(exc)} - payload["error"] = RequestError.internal_error(data).to_error_obj() - await self._send_obj(payload) + err = RequestError.internal_error(data) + payload["error"] = err.to_error_obj() + await self._sender.send(payload) + raise err from None - async def _handle_notification(self, message: dict[str, Any]) -> None: + async def _run_notification(self, message: dict[str, Any]) -> None: with contextlib.suppress(Exception): await self._handler(message["method"], message.get("params"), True) async def _handle_response(self, message: dict[str, Any]) -> None: - pending = self._pending.pop(message["id"], None) - if pending is None: - return + request_id = message["id"] + result = message.get("result") if "result" in message: - pending.future.set_result(message.get("result")) + self._state.resolve_outgoing(request_id, result) return if "error" in message: error_obj = message.get("error") or {} - pending.future.set_exception( + self._state.reject_outgoing( + request_id, RequestError( error_obj.get("code", -32603), error_obj.get("message", "Error"), error_obj.get("data"), - ) + ), ) return - pending.future.set_result(None) - - async def _send_obj(self, payload: dict[str, Any]) -> None: - data = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8") - async with self._write_lock: - self._writer.write(data) - with contextlib.suppress(ConnectionError, RuntimeError): - await self._writer.drain() + self._state.resolve_outgoing(request_id, None) + + def _on_receive_error(self, task: asyncio.Task[Any], exc: BaseException) -> None: + logging.exception("Receive loop failed", exc_info=exc) + self._state.reject_all_outgoing(exc) + + def _on_task_error(self, task: asyncio.Task[Any], exc: BaseException) -> None: + logging.exception("Background task failed", exc_info=exc) + + def _default_dispatcher_factory( + self, + queue: MessageQueue, + supervisor: TaskSupervisor, + state: MessageStateStore, + request_runner: RequestRunner, + notification_runner: NotificationRunner, + ) -> MessageDispatcher: + return DefaultMessageDispatcher( + queue=queue, + supervisor=supervisor, + store=state, + request_runner=request_runner, + notification_runner=notification_runner, + ) + + def _default_sender_factory(self, writer: asyncio.StreamWriter, supervisor: TaskSupervisor) -> MessageSender: + return MessageSender(writer, supervisor) diff --git a/src/acp/task/__init__.py b/src/acp/task/__init__.py new file mode 100644 index 0000000..2896fbf --- /dev/null +++ b/src/acp/task/__init__.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +__all__ = ["RpcTask", "RpcTaskKind"] + + +class RpcTaskKind(Enum): + REQUEST = "request" + NOTIFICATION = "notification" + + +@dataclass(slots=True) +class RpcTask: + kind: RpcTaskKind + message: dict[str, Any] + + +from .dispatcher import ( # noqa: E402 + DefaultMessageDispatcher, + MessageDispatcher, + NotificationRunner, + RequestRunner, +) +from .queue import InMemoryMessageQueue, MessageQueue # noqa: E402 +from .sender import MessageSender, SenderFactory # noqa: E402 +from .state import InMemoryMessageStateStore, MessageStateStore # noqa: E402 +from .supervisor import TaskSupervisor # noqa: E402 + +__all__ += [ + "DefaultMessageDispatcher", + "InMemoryMessageQueue", + "InMemoryMessageStateStore", + "MessageDispatcher", + "MessageQueue", + "MessageSender", + "MessageStateStore", + "NotificationRunner", + "RequestRunner", + "SenderFactory", + "TaskSupervisor", +] diff --git a/src/acp/task/dispatcher.py b/src/acp/task/dispatcher.py new file mode 100644 index 0000000..e8c5e76 --- /dev/null +++ b/src/acp/task/dispatcher.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from contextlib import suppress +from typing import Any, Protocol + +from . import RpcTaskKind +from .queue import MessageQueue +from .state import MessageStateStore +from .supervisor import TaskSupervisor + +__all__ = [ + "DefaultMessageDispatcher", + "MessageDispatcher", + "NotificationRunner", + "RequestRunner", +] + + +RequestRunner = Callable[[dict[str, Any]], Awaitable[Any]] +NotificationRunner = Callable[[dict[str, Any]], Awaitable[None]] + + +class MessageDispatcher(Protocol): + def start(self) -> None: ... + + async def stop(self) -> None: ... + + +class DefaultMessageDispatcher(MessageDispatcher): + """Background worker that consumes RPC tasks from a broker, coordinating with the store.""" + + def __init__( + self, + *, + queue: MessageQueue, + supervisor: TaskSupervisor, + store: MessageStateStore, + request_runner: RequestRunner, + notification_runner: NotificationRunner, + ) -> None: + self._queue = queue + self._supervisor = supervisor + self._store = store + self._request_runner = request_runner + self._notification_runner = notification_runner + self._task: asyncio.Task[None] | None = None + + def start(self) -> None: + if self._task is not None: + msg = "dispatcher already started" + raise RuntimeError(msg) + self._task = self._supervisor.create(self._run(), name="acp.Dispatcher.loop") + + async def _run(self) -> None: + try: + async for task in self._queue: + try: + if task.kind is RpcTaskKind.REQUEST: + await self._dispatch_request(task.message) + else: + await self._dispatch_notification(task.message) + finally: + self._queue.task_done() + except asyncio.CancelledError: + return + + async def stop(self) -> None: + await self._queue.close() + if self._task is not None: + with suppress(asyncio.CancelledError): + await self._task + self._task = None + + async def _dispatch_request(self, message: dict[str, Any]) -> None: + record = self._store.begin_incoming(message.get("method", ""), message.get("params")) + + async def runner() -> None: + try: + result = await self._request_runner(message) + except Exception as exc: + self._store.fail_incoming(record, exc) + raise + else: + self._store.complete_incoming(record, result) + + self._supervisor.create(runner(), name="acp.Dispatcher.request") + + async def _dispatch_notification(self, message: dict[str, Any]) -> None: + async def runner() -> None: + await self._notification_runner(message) + + self._supervisor.create(runner(), name="acp.Dispatcher.notification") diff --git a/src/acp/task/queue.py b/src/acp/task/queue.py new file mode 100644 index 0000000..6052635 --- /dev/null +++ b/src/acp/task/queue.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from contextlib import suppress +from typing import Protocol + +from . import RpcTask + +__all__ = ["InMemoryMessageQueue", "MessageQueue"] + + +class MessageQueue(Protocol): + async def publish(self, task: RpcTask) -> None: ... + + async def close(self) -> None: ... + + def task_done(self) -> None: ... + + async def join(self) -> None: ... + + def __aiter__(self) -> AsyncIterator[RpcTask]: ... + + +class InMemoryMessageQueue: + """Simple in-memory broker for RPC task dispatch.""" + + def __init__(self, *, maxsize: int = 0) -> None: + self._queue: asyncio.Queue[RpcTask | None] = asyncio.Queue(maxsize=maxsize) + self._closed = False + + async def publish(self, task: RpcTask) -> None: + if self._closed: + msg = "mssage queue already closed" + raise RuntimeError(msg) + await self._queue.put(task) + + async def close(self) -> None: + if self._closed: + return + self._closed = True + await self._queue.put(None) + + async def join(self) -> None: + await self._queue.join() + + def task_done(self) -> None: + with suppress(ValueError): + self._queue.task_done() + + def __aiter__(self) -> AsyncIterator[RpcTask]: + return _QueueIterator(self) + + +class _QueueIterator: + def __init__(self, queue: InMemoryMessageQueue) -> None: + self._queue = queue + + def __aiter__(self) -> _QueueIterator: + return self + + async def __anext__(self) -> RpcTask: + item = await self._queue._queue.get() + if item is None: + self._queue.task_done() + raise StopAsyncIteration + return item diff --git a/src/acp/task/sender.py b/src/acp/task/sender.py new file mode 100644 index 0000000..5662af2 --- /dev/null +++ b/src/acp/task/sender.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from .supervisor import TaskSupervisor + +__all__ = ["MessageSender", "SenderFactory"] + + +SenderFactory = Callable[[asyncio.StreamWriter, TaskSupervisor], "MessageSender"] + + +@dataclass(slots=True) +class _PendingSend: + payload: bytes + future: asyncio.Future[None] + + +class MessageSender: + def __init__(self, writer: asyncio.StreamWriter, supervisor: TaskSupervisor) -> None: + self._writer = writer + self._queue: asyncio.Queue[_PendingSend | None] = asyncio.Queue() + self._closed = False + self._task = supervisor.create(self._loop(), name="acp.Sender.loop", on_error=self._on_error) + + async def send(self, payload: dict[str, Any]) -> None: + data = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8") + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + await self._queue.put(_PendingSend(data, future)) + await future + + async def close(self) -> None: + if self._closed: + return + self._closed = True + await self._queue.put(None) + if self._task is not None: + with contextlib.suppress(asyncio.CancelledError): + await self._task + + async def _loop(self) -> None: + try: + while True: + item = await self._queue.get() + if item is None: + return + try: + self._writer.write(item.payload) + await self._writer.drain() + except Exception as exc: + if not item.future.done(): + item.future.set_exception(exc) + raise + else: + if not item.future.done(): + item.future.set_result(None) + except asyncio.CancelledError: + return + + def _on_error(self, task: asyncio.Task[Any], exc: BaseException) -> None: + logging.exception("Send loop failed", exc_info=exc) diff --git a/src/acp/task/state.py b/src/acp/task/state.py new file mode 100644 index 0000000..65baf0c --- /dev/null +++ b/src/acp/task/state.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any, Protocol + +__all__ = [ + "InMemoryMessageStateStore", + "IncomingMessage", + "MessageStateStore", + "OutgoingMessage", +] + + +@dataclass(slots=True) +class OutgoingMessage: + request_id: int + method: str + future: asyncio.Future[Any] + + +@dataclass(slots=True) +class IncomingMessage: + method: str + params: Any + status: str = "pending" + result: Any = None + error: Any = None + + +class MessageStateStore(Protocol): + def register_outgoing(self, request_id: int, method: str) -> asyncio.Future[Any]: ... + + def resolve_outgoing(self, request_id: int, result: Any) -> None: ... + + def reject_outgoing(self, request_id: int, error: Any) -> None: ... + + def reject_all_outgoing(self, error: Any) -> None: ... + + def begin_incoming(self, method: str, params: Any) -> IncomingMessage: ... + + def complete_incoming(self, record: IncomingMessage, result: Any) -> None: ... + + def fail_incoming(self, record: IncomingMessage, error: Any) -> None: ... + + +class InMemoryMessageStateStore(MessageStateStore): + def __init__(self) -> None: + self._outgoing: dict[int, OutgoingMessage] = {} + self._incoming: list[IncomingMessage] = [] + + def register_outgoing(self, request_id: int, method: str) -> asyncio.Future[Any]: + future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + self._outgoing[request_id] = OutgoingMessage(request_id, method, future) + return future + + def resolve_outgoing(self, request_id: int, result: Any) -> None: + record = self._outgoing.pop(request_id, None) + if record and not record.future.done(): + record.future.set_result(result) + + def reject_outgoing(self, request_id: int, error: Any) -> None: + record = self._outgoing.pop(request_id, None) + if record and not record.future.done(): + record.future.set_exception(error) + + def reject_all_outgoing(self, error: Any) -> None: + for record in self._outgoing.values(): + if not record.future.done(): + record.future.set_exception(error) + self._outgoing.clear() + + def begin_incoming(self, method: str, params: Any) -> IncomingMessage: + record = IncomingMessage(method=method, params=params) + self._incoming.append(record) + return record + + def complete_incoming(self, record: IncomingMessage, result: Any) -> None: + record.status = "completed" + record.result = result + + def fail_incoming(self, record: IncomingMessage, error: Any) -> None: + record.status = "failed" + record.error = error diff --git a/src/acp/task/supervisor.py b/src/acp/task/supervisor.py new file mode 100644 index 0000000..7ad7e6d --- /dev/null +++ b/src/acp/task/supervisor.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable +from contextlib import suppress +from typing import Any + +__all__ = ["TaskSupervisor"] + +ErrorHandler = Callable[[asyncio.Task[Any], BaseException], None] + + +class TaskSupervisor: + """Track background tasks and provide graceful shutdown semantics. + + Inspired by fasta2a's task manager, this supervisor keeps a registry of + asyncio tasks created for request handling so they can be cancelled and + awaited reliably when the connection closes. + """ + + def __init__(self, *, source: str) -> None: + self._source = source + self._tasks: set[asyncio.Task[Any]] = set() + self._closed = False + self._error_handlers: list[ErrorHandler] = [] + + def add_error_handler(self, handler: ErrorHandler) -> None: + self._error_handlers.append(handler) + + def create( + self, + coroutine: Awaitable[Any], + *, + name: str | None = None, + on_error: ErrorHandler | None = None, + ) -> asyncio.Task[Any]: + if self._closed: + msg = f"TaskSupervisor for {self._source} already closed" + raise RuntimeError(msg) + task = asyncio.create_task(coroutine, name=name) + self._tasks.add(task) + task.add_done_callback(lambda t: self._on_done(t, on_error)) + return task + + def _on_done(self, task: asyncio.Task[Any], on_error: ErrorHandler | None) -> None: + self._tasks.discard(task) + if task.cancelled(): + return + try: + task.result() + except Exception as exc: + handled = False + if on_error is not None: + try: + on_error(task, exc) + handled = True + except Exception: + logging.exception("Error in %s task-specific error handler", self._source) + if not handled: + for handler in self._error_handlers: + try: + handler(task, exc) + handled = True + except Exception: + logging.exception("Error in %s supervisor error handler", self._source) + if not handled: + logging.exception("Unhandled error in %s task", self._source) + + async def shutdown(self) -> None: + self._closed = True + if not self._tasks: + return + tasks = list(self._tasks) + for task in tasks: + task.cancel() + for task in tasks: + with suppress(asyncio.CancelledError): + await task + self._tasks.clear() From 0da09a32c532890c251f4d52e97011361593ad8a Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 11 Oct 2025 05:00:11 +0800 Subject: [PATCH 4/6] feat: intro router to manager handlers Signed-off-by: Chojan Shang --- src/acp/agent/connection.py | 9 +- src/acp/agent/handlers.py | 104 ------------------- src/acp/agent/router.py | 76 ++++++++++++++ src/acp/client/connection.py | 9 +- src/acp/client/handlers.py | 102 ------------------- src/acp/client/router.py | 96 ++++++++++++++++++ src/acp/router.py | 192 +++++++++++++++++++++++++++++++++++ 7 files changed, 378 insertions(+), 210 deletions(-) delete mode 100644 src/acp/agent/handlers.py create mode 100644 src/acp/agent/router.py delete mode 100644 src/acp/client/handlers.py create mode 100644 src/acp/client/router.py create mode 100644 src/acp/router.py diff --git a/src/acp/agent/connection.py b/src/acp/agent/connection.py index 3b1b301..c26fe07 100644 --- a/src/acp/agent/connection.py +++ b/src/acp/agent/connection.py @@ -28,7 +28,7 @@ ) from ..terminal import TerminalHandle from ..utils import notify_model, request_model, request_optional_model -from .handlers import dispatch_agent_method +from .router import build_agent_router __all__ = ["AgentSideConnection"] @@ -52,8 +52,13 @@ def __init__( self._conn = Connection(handler, input_stream, output_stream) def _create_handler(self, agent: Agent) -> MethodHandler: + router = build_agent_router(agent) + async def handler(method: str, params: Any | None, is_notification: bool) -> Any: - return await dispatch_agent_method(agent, method, params, is_notification) + if is_notification: + await router.dispatch_notification(method, params) + return None + return await router.dispatch_request(method, params) return handler diff --git a/src/acp/agent/handlers.py b/src/acp/agent/handlers.py deleted file mode 100644 index 0ee6267..0000000 --- a/src/acp/agent/handlers.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from ..exceptions import RequestError -from ..interfaces import Agent -from ..meta import AGENT_METHODS -from ..schema import ( - AuthenticateRequest, - CancelNotification, - InitializeRequest, - LoadSessionRequest, - NewSessionRequest, - PromptRequest, - SetSessionModelRequest, - SetSessionModeRequest, -) -from ..utils import normalize_result - -__all__ = [ - "NO_MATCH", - "dispatch_agent_method", -] - - -class _NoMatch: - """Sentinel returned by routing helpers when no handler matches.""" - - -NO_MATCH = _NoMatch() - - -async def _handle_agent_init(agent: Agent, method: str, params: Any | None) -> Any: - if method == AGENT_METHODS["initialize"]: - request = InitializeRequest.model_validate(params) - return await agent.initialize(request) - if method == AGENT_METHODS["session_new"]: - request = NewSessionRequest.model_validate(params) - return await agent.newSession(request) - return NO_MATCH - - -async def _handle_agent_session(agent: Agent, method: str, params: Any | None) -> Any: - if method == AGENT_METHODS["session_load"]: - if not hasattr(agent, "loadSession"): - raise RequestError.method_not_found(method) - request = LoadSessionRequest.model_validate(params) - result = await agent.loadSession(request) - return normalize_result(result) - if method == AGENT_METHODS["session_set_mode"]: - if not hasattr(agent, "setSessionMode"): - raise RequestError.method_not_found(method) - request = SetSessionModeRequest.model_validate(params) - result = await agent.setSessionMode(request) - return normalize_result(result) - if method == AGENT_METHODS["session_prompt"]: - request = PromptRequest.model_validate(params) - return await agent.prompt(request) - if method == AGENT_METHODS["session_set_model"]: - if not hasattr(agent, "setSessionModel"): - raise RequestError.method_not_found(method) - request = SetSessionModelRequest.model_validate(params) - result = await agent.setSessionModel(request) - return normalize_result(result) - if method == AGENT_METHODS["session_cancel"]: - request = CancelNotification.model_validate(params) - return await agent.cancel(request) - return NO_MATCH - - -async def _handle_agent_auth(agent: Agent, method: str, params: Any | None) -> Any: - if method == AGENT_METHODS["authenticate"]: - if not hasattr(agent, "authenticate"): - raise RequestError.method_not_found(method) - request = AuthenticateRequest.model_validate(params) - result = await agent.authenticate(request) - return normalize_result(result) - return NO_MATCH - - -async def _handle_agent_extensions(agent: Agent, method: str, params: Any | None, is_notification: bool) -> Any: - if isinstance(method, str) and method.startswith("_"): - ext_name = method[1:] - if is_notification: - if hasattr(agent, "extNotification"): - await agent.extNotification(ext_name, params or {}) # type: ignore[arg-type] - return None - return None - if hasattr(agent, "extMethod"): - return await agent.extMethod(ext_name, params or {}) # type: ignore[arg-type] - return NO_MATCH - return NO_MATCH - - -async def dispatch_agent_method(agent: Agent, method: str, params: Any | None, is_notification: bool) -> Any: - """Dispatch agent-bound methods, mirroring the upstream ACP routing.""" - for resolver in (_handle_agent_init, _handle_agent_session, _handle_agent_auth): - result = await resolver(agent, method, params) - if result is not NO_MATCH: - return result - extension_result = await _handle_agent_extensions(agent, method, params, is_notification) - if extension_result is not NO_MATCH: - return extension_result - raise RequestError.method_not_found(method) diff --git a/src/acp/agent/router.py b/src/acp/agent/router.py new file mode 100644 index 0000000..515d804 --- /dev/null +++ b/src/acp/agent/router.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import Any + +from ..exceptions import RequestError +from ..interfaces import Agent +from ..meta import AGENT_METHODS +from ..router import MessageRouter, RouterBuilder +from ..schema import ( + AuthenticateRequest, + CancelNotification, + InitializeRequest, + LoadSessionRequest, + NewSessionRequest, + PromptRequest, + SetSessionModelRequest, + SetSessionModeRequest, +) +from ..utils import normalize_result + +__all__ = ["build_agent_router"] + + +def build_agent_router(agent: Agent) -> MessageRouter: + builder = RouterBuilder() + + builder.request_attr(AGENT_METHODS["initialize"], InitializeRequest, agent, "initialize") + builder.request_attr(AGENT_METHODS["session_new"], NewSessionRequest, agent, "newSession") + builder.request_attr( + AGENT_METHODS["session_load"], + LoadSessionRequest, + agent, + "loadSession", + adapt_result=normalize_result, + ) + builder.request_attr( + AGENT_METHODS["session_set_mode"], + SetSessionModeRequest, + agent, + "setSessionMode", + adapt_result=normalize_result, + ) + builder.request_attr(AGENT_METHODS["session_prompt"], PromptRequest, agent, "prompt") + builder.request_attr( + AGENT_METHODS["session_set_model"], + SetSessionModelRequest, + agent, + "setSessionModel", + adapt_result=normalize_result, + ) + builder.request_attr( + AGENT_METHODS["authenticate"], + AuthenticateRequest, + agent, + "authenticate", + adapt_result=normalize_result, + ) + + builder.notification_attr(AGENT_METHODS["session_cancel"], CancelNotification, agent, "cancel") + + async def handle_extension_request(name: str, payload: dict[str, Any]) -> Any: + ext = getattr(agent, "extMethod", None) + if ext is None: + raise RequestError.method_not_found(f"_{name}") + return await ext(name, payload) + + async def handle_extension_notification(name: str, payload: dict[str, Any]) -> None: + ext = getattr(agent, "extNotification", None) + if ext is None: + return + await ext(name, payload) + + return builder.build( + request_extensions=handle_extension_request, + notification_extensions=handle_extension_notification, + ) diff --git a/src/acp/client/connection.py b/src/acp/client/connection.py index 28e6211..1177dcd 100644 --- a/src/acp/client/connection.py +++ b/src/acp/client/connection.py @@ -29,7 +29,7 @@ request_model, request_model_from_dict, ) -from .handlers import dispatch_client_method +from .router import build_client_router __all__ = ["ClientSideConnection"] @@ -53,8 +53,13 @@ def __init__( self._conn = Connection(handler, input_stream, output_stream) def _create_handler(self, client: Client) -> MethodHandler: + router = build_client_router(client) + async def handler(method: str, params: Any | None, is_notification: bool) -> Any: - return await dispatch_client_method(client, method, params, is_notification) + if is_notification: + await router.dispatch_notification(method, params) + return None + return await router.dispatch_request(method, params) return handler diff --git a/src/acp/client/handlers.py b/src/acp/client/handlers.py deleted file mode 100644 index 7515d13..0000000 --- a/src/acp/client/handlers.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from ..exceptions import RequestError -from ..interfaces import Client -from ..meta import CLIENT_METHODS -from ..schema import ( - CreateTerminalRequest, - KillTerminalCommandRequest, - ReadTextFileRequest, - ReleaseTerminalRequest, - RequestPermissionRequest, - SessionNotification, - TerminalOutputRequest, - WaitForTerminalExitRequest, - WriteTextFileRequest, -) -from ..utils import normalize_result - -__all__ = ["NO_MATCH", "dispatch_client_method"] - - -class _NoMatch: - """Sentinel returned by routing helpers when no handler matches.""" - - -NO_MATCH = _NoMatch() - - -async def _handle_client_core(client: Client, method: str, params: Any | None) -> Any: - if method == CLIENT_METHODS["fs_write_text_file"]: - request = WriteTextFileRequest.model_validate(params) - return await client.writeTextFile(request) - if method == CLIENT_METHODS["fs_read_text_file"]: - request = ReadTextFileRequest.model_validate(params) - return await client.readTextFile(request) - if method == CLIENT_METHODS["session_request_permission"]: - request = RequestPermissionRequest.model_validate(params) - return await client.requestPermission(request) - if method == CLIENT_METHODS["session_update"]: - notification = SessionNotification.model_validate(params) - await client.sessionUpdate(notification) - return None - return NO_MATCH - - -async def _handle_client_terminal(client: Client, method: str, params: Any | None) -> Any: # noqa: C901 - if method == CLIENT_METHODS["terminal_create"]: - if not hasattr(client, "createTerminal"): - return None - request = CreateTerminalRequest.model_validate(params) - return await client.createTerminal(request) - if method == CLIENT_METHODS["terminal_output"]: - if not hasattr(client, "terminalOutput"): - return None - request = TerminalOutputRequest.model_validate(params) - return await client.terminalOutput(request) - if method == CLIENT_METHODS["terminal_release"]: - if not hasattr(client, "releaseTerminal"): - return {} - request = ReleaseTerminalRequest.model_validate(params) - result = await client.releaseTerminal(request) - return normalize_result(result) - if method == CLIENT_METHODS["terminal_wait_for_exit"]: - if not hasattr(client, "waitForTerminalExit"): - return None - request = WaitForTerminalExitRequest.model_validate(params) - return await client.waitForTerminalExit(request) - if method == CLIENT_METHODS["terminal_kill"]: - if not hasattr(client, "killTerminal"): - return {} - request = KillTerminalCommandRequest.model_validate(params) - result = await client.killTerminal(request) - return normalize_result(result) - return NO_MATCH - - -async def _handle_client_extensions(client: Client, method: str, params: Any | None, is_notification: bool) -> Any: - if isinstance(method, str) and method.startswith("_"): - ext_name = method[1:] - if is_notification: - if hasattr(client, "extNotification"): - await client.extNotification(ext_name, params or {}) # type: ignore[arg-type] - return None - return None - if hasattr(client, "extMethod"): - return await client.extMethod(ext_name, params or {}) # type: ignore[arg-type] - return NO_MATCH - return NO_MATCH - - -async def dispatch_client_method(client: Client, method: str, params: Any | None, is_notification: bool) -> Any: - """Dispatch client-bound methods mirroring upstream ACP routing.""" - for resolver in (_handle_client_core, _handle_client_terminal): - result = await resolver(client, method, params) - if result is not NO_MATCH: - return result - extension_result = await _handle_client_extensions(client, method, params, is_notification) - if extension_result is not NO_MATCH: - return extension_result - raise RequestError.method_not_found(method) diff --git a/src/acp/client/router.py b/src/acp/client/router.py new file mode 100644 index 0000000..9f0b85f --- /dev/null +++ b/src/acp/client/router.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Any + +from ..exceptions import RequestError +from ..interfaces import Client +from ..meta import CLIENT_METHODS +from ..router import MessageRouter, RouterBuilder +from ..schema import ( + CreateTerminalRequest, + KillTerminalCommandRequest, + ReadTextFileRequest, + ReleaseTerminalRequest, + RequestPermissionRequest, + SessionNotification, + TerminalOutputRequest, + WaitForTerminalExitRequest, + WriteTextFileRequest, +) +from ..utils import normalize_result + +__all__ = ["build_client_router"] + + +def build_client_router(client: Client) -> MessageRouter: + builder = RouterBuilder() + + builder.request_attr(CLIENT_METHODS["fs_write_text_file"], WriteTextFileRequest, client, "writeTextFile") + builder.request_attr(CLIENT_METHODS["fs_read_text_file"], ReadTextFileRequest, client, "readTextFile") + builder.request_attr( + CLIENT_METHODS["session_request_permission"], + RequestPermissionRequest, + client, + "requestPermission", + ) + builder.request_attr( + CLIENT_METHODS["terminal_create"], + CreateTerminalRequest, + client, + "createTerminal", + optional=True, + default_result=None, + ) + builder.request_attr( + CLIENT_METHODS["terminal_output"], + TerminalOutputRequest, + client, + "terminalOutput", + optional=True, + default_result=None, + ) + builder.request_attr( + CLIENT_METHODS["terminal_release"], + ReleaseTerminalRequest, + client, + "releaseTerminal", + optional=True, + default_result={}, + adapt_result=normalize_result, + ) + builder.request_attr( + CLIENT_METHODS["terminal_wait_for_exit"], + WaitForTerminalExitRequest, + client, + "waitForTerminalExit", + optional=True, + default_result=None, + ) + builder.request_attr( + CLIENT_METHODS["terminal_kill"], + KillTerminalCommandRequest, + client, + "killTerminal", + optional=True, + default_result={}, + adapt_result=normalize_result, + ) + + builder.notification_attr(CLIENT_METHODS["session_update"], SessionNotification, client, "sessionUpdate") + + async def handle_extension_request(name: str, payload: dict[str, Any]) -> Any: + ext = getattr(client, "extMethod", None) + if ext is None: + raise RequestError.method_not_found(f"_{name}") + return await ext(name, payload) + + async def handle_extension_notification(name: str, payload: dict[str, Any]) -> None: + ext = getattr(client, "extNotification", None) + if ext is None: + return + await ext(name, payload) + + return builder.build( + request_extensions=handle_extension_request, + notification_extensions=handle_extension_notification, + ) diff --git a/src/acp/router.py b/src/acp/router.py new file mode 100644 index 0000000..f50f9b7 --- /dev/null +++ b/src/acp/router.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Literal + +from pydantic import BaseModel + +from .exceptions import RequestError + +__all__ = [ + "MessageRouter", + "Route", + "RouterBuilder", + "attribute_handler", +] + + +AsyncHandler = Callable[[Any], Awaitable[Any | None]] + + +@dataclass(slots=True) +class Route: + method: str + model: type[BaseModel] + handle: Callable[[], AsyncHandler | None] + kind: Literal["request", "notification"] + optional: bool = False + default_result: Any = None + adapt_result: Callable[[Any | None], Any] | None = None + + +class MessageRouter: + def __init__( + self, + routes: Sequence[Route], + *, + request_extensions: Callable[[str, dict[str, Any]], Awaitable[Any]] | None = None, + notification_extensions: Callable[[str, dict[str, Any]], Awaitable[None]] | None = None, + ) -> None: + self._requests: Mapping[str, Route] = {route.method: route for route in routes if route.kind == "request"} + self._notifications: Mapping[str, Route] = { + route.method: route for route in routes if route.kind == "notification" + } + self._request_extensions = request_extensions + self._notification_extensions = notification_extensions + + async def dispatch_request(self, method: str, params: Any | None) -> Any: + if isinstance(method, str) and method.startswith("_"): + if self._request_extensions is None: + raise RequestError.method_not_found(method) + payload = params if isinstance(params, dict) else {} + return await self._request_extensions(method[1:], payload) + + route = self._requests.get(method) + if route is None: + raise RequestError.method_not_found(method) + model = route.model + parsed = model.model_validate(params) + + handler = route.handle() + if handler is None: + if route.optional: + return route.default_result + raise RequestError.method_not_found(method) + + result = await handler(parsed) + if route.adapt_result is not None: + return route.adapt_result(result) + return result + + async def dispatch_notification(self, method: str, params: Any | None) -> None: + if isinstance(method, str) and method.startswith("_"): + if self._notification_extensions is None: + return + payload = params if isinstance(params, dict) else {} + await self._notification_extensions(method[1:], payload) + return + + route = self._notifications.get(method) + if route is None: + raise RequestError.method_not_found(method) + model = route.model + parsed = model.model_validate(params) + + handler = route.handle() + if handler is None: + if route.optional: + return + raise RequestError.method_not_found(method) + await handler(parsed) + + +class RouterBuilder: + def __init__(self) -> None: + self._routes: list[Route] = [] + + def request( + self, + method: str, + model: type[BaseModel], + *, + optional: bool = False, + default_result: Any = None, + adapt_result: Callable[[Any | None], Any] | None = None, + ) -> Callable[[Callable[[], AsyncHandler | None]], Callable[[], AsyncHandler | None]]: + def decorator(factory: Callable[[], AsyncHandler | None]) -> Callable[[], AsyncHandler | None]: + self._routes.append( + Route( + method=method, + model=model, + handle=factory, + kind="request", + optional=optional, + default_result=default_result, + adapt_result=adapt_result, + ) + ) + return factory + + return decorator + + def notification( + self, + method: str, + model: type[BaseModel], + *, + optional: bool = False, + ) -> Callable[[Callable[[], AsyncHandler | None]], Callable[[], AsyncHandler | None]]: + def decorator(factory: Callable[[], AsyncHandler | None]) -> Callable[[], AsyncHandler | None]: + self._routes.append( + Route( + method=method, + model=model, + handle=factory, + kind="notification", + optional=optional, + ) + ) + return factory + + return decorator + + def build( + self, + *, + request_extensions: Callable[[str, dict[str, Any]], Awaitable[Any]] | None = None, + notification_extensions: Callable[[str, dict[str, Any]], Awaitable[None]] | None = None, + ) -> MessageRouter: + return MessageRouter( + routes=self._routes, + request_extensions=request_extensions, + notification_extensions=notification_extensions, + ) + + def request_attr( + self, + method: str, + model: type[BaseModel], + obj: Any, + attr: str, + *, + optional: bool = False, + default_result: Any = None, + adapt_result: Callable[[Any | None], Any] | None = None, + ) -> None: + self.request( + method, + model, + optional=optional, + default_result=default_result, + adapt_result=adapt_result, + )(attribute_handler(obj, attr)) + + def notification_attr( + self, + method: str, + model: type[BaseModel], + obj: Any, + attr: str, + *, + optional: bool = False, + ) -> None: + self.notification(method, model, optional=optional)(attribute_handler(obj, attr)) + + +def attribute_handler(obj: Any, attr: str) -> Callable[[], AsyncHandler | None]: + def factory() -> AsyncHandler | None: + func = getattr(obj, attr, None) + return func if callable(func) else None + + return factory From fed7bd404ac8115e5243361545218ac1448957a6 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 11 Oct 2025 05:11:42 +0800 Subject: [PATCH 5/6] feat: intro telemetry Signed-off-by: Chojan Shang --- pyproject.toml | 3 + src/acp/connection.py | 59 ++++++----- src/acp/telemetry.py | 41 ++++++++ uv.lock | 233 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 src/acp/telemetry.py diff --git a/pyproject.toml b/pyproject.toml index 9c3409f..a5d9435 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ dev = [ "python-dotenv>=1.1.1", ] +[project.optional-dependencies] +logfire = ["logfire>=0.14"] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/src/acp/connection.py b/src/acp/connection.py index 951b5e4..960ee37 100644 --- a/src/acp/connection.py +++ b/src/acp/connection.py @@ -25,6 +25,7 @@ SenderFactory, TaskSupervisor, ) +from .telemetry import span_context JsonValue = Any MethodHandler = Callable[[str, JsonValue | None, bool], Awaitable[JsonValue | None]] @@ -135,35 +136,41 @@ async def _process_message(self, message: dict[str, Any]) -> None: async def _run_request(self, message: dict[str, Any]) -> Any: payload: dict[str, Any] = {"jsonrpc": "2.0", "id": message["id"]} - try: - result = await self._handler(message["method"], message.get("params"), False) - if isinstance(result, BaseModel): - result = result.model_dump() - payload["result"] = result if result is not None else None - await self._sender.send(payload) - return payload.get("result") - except RequestError as exc: - payload["error"] = exc.to_error_obj() - await self._sender.send(payload) - raise - except ValidationError as exc: - err = RequestError.invalid_params({"errors": exc.errors()}) - payload["error"] = err.to_error_obj() - await self._sender.send(payload) - raise err from None - except Exception as exc: + method = message["method"] + with span_context( + "acp.request", + attributes={"method": method}, + ): try: - data = json.loads(str(exc)) - except Exception: - data = {"details": str(exc)} - err = RequestError.internal_error(data) - payload["error"] = err.to_error_obj() - await self._sender.send(payload) - raise err from None + result = await self._handler(method, message.get("params"), False) + if isinstance(result, BaseModel): + result = result.model_dump() + payload["result"] = result if result is not None else None + await self._sender.send(payload) + return payload.get("result") + except RequestError as exc: + payload["error"] = exc.to_error_obj() + await self._sender.send(payload) + raise + except ValidationError as exc: + err = RequestError.invalid_params({"errors": exc.errors()}) + payload["error"] = err.to_error_obj() + await self._sender.send(payload) + raise err from None + except Exception as exc: + try: + data = json.loads(str(exc)) + except Exception: + data = {"details": str(exc)} + err = RequestError.internal_error(data) + payload["error"] = err.to_error_obj() + await self._sender.send(payload) + raise err from None async def _run_notification(self, message: dict[str, Any]) -> None: - with contextlib.suppress(Exception): - await self._handler(message["method"], message.get("params"), True) + method = message["method"] + with span_context("acp.notification", attributes={"method": method}), contextlib.suppress(Exception): + await self._handler(method, message.get("params"), True) async def _handle_response(self, message: dict[str, Any]) -> None: request_id = message["id"] diff --git a/src/acp/telemetry.py b/src/acp/telemetry.py new file mode 100644 index 0000000..1d7e340 --- /dev/null +++ b/src/acp/telemetry.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import os +from collections.abc import Mapping +from contextlib import AbstractContextManager, ExitStack, nullcontext +from typing import Any + +try: + from logfire import span as logfire_span +except Exception: # pragma: no cover - logfire is optional + logfire_span = None # type: ignore[assignment] +else: # pragma: no cover - optional + os.environ.setdefault("LOGFIRE_IGNORE_NO_CONFIG", "1") + +try: # pragma: no cover - opentelemetry is optional + from opentelemetry.trace import get_tracer as otel_get_tracer +except Exception: # pragma: no cover - opentelemetry is optional + otel_get_tracer = None # type: ignore[assignment] + +DEFAULT_TAGS = ["acp"] +TRACER = otel_get_tracer(__name__) if otel_get_tracer else None + + +def _start_tracer_span(name: str, *, attributes: Mapping[str, Any] | None = None) -> AbstractContextManager[Any]: + if TRACER is None: + return nullcontext() + attrs = dict(attributes or {}) + return TRACER.start_as_current_span(name, attributes=attrs) + + +def span_context(name: str, *, attributes: Mapping[str, Any] | None = None) -> AbstractContextManager[None]: + if logfire_span is None and TRACER is None: + return nullcontext() + stack = ExitStack() + attrs: dict[str, Any] = {"logfire.tags": DEFAULT_TAGS} + if attributes: + attrs.update(attributes) + if logfire_span is not None: + stack.enter_context(logfire_span(name, attributes=attrs)) + stack.enter_context(_start_tracer_span(name, attributes=attributes)) + return stack diff --git a/uv.lock b/uv.lock index 33dffe2..7de1f2b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10, <4.0" [[package]] @@ -10,6 +10,11 @@ dependencies = [ { name = "pydantic" }, ] +[package.optional-dependencies] +logfire = [ + { name = "logfire" }, +] + [package.dev-dependencies] dev = [ { name = "datamodel-code-generator" }, @@ -28,7 +33,11 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "pydantic", specifier = ">=2.7" }] +requires-dist = [ + { name = "logfire", marker = "extra == 'logfire'", specifier = ">=0.14" }, + { name = "pydantic", specifier = ">=2.7" }, +] +provides-extras = ["logfire"] [package.metadata.requires-dev] dev = [ @@ -474,6 +483,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "fastuuid" version = "0.12.0" @@ -631,6 +649,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, +] + [[package]] name = "griffe" version = "1.14.0" @@ -921,6 +951,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/f4/980cc81c21424026dcb48a541654fd6f4286891825a3d0dd51f02b65cbc3/litellm-1.76.2-py3-none-any.whl", hash = "sha256:a9a2ef64a598b5b4ae245f1de6afc400856477cd6f708ff633d95e2275605a45", size = 8973847, upload-time = "2025-09-04T00:25:05.353Z" }, ] +[[package]] +name = "logfire" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/79/6137e240c7d798e25e2cfcf0c7f209aa5136e138c50d3809f75b1f032748/logfire-4.13.0.tar.gz", hash = "sha256:692a203e75343ac9b1a2fed1fbb4ed62d6285cfc63439c985087b8e8972024f1", size = 547685, upload-time = "2025-10-09T17:34:13.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/f9/9ce9a483d14e61be9eb01209b077c80a424f6ae9200aa1e3ce56cb6dff26/logfire-4.13.0-py3-none-any.whl", hash = "sha256:6da1ecf9f1d73dda2faea24ab10c9405ddc46dcd7e03c29c0ca3ead4ec59e56e", size = 228113, upload-time = "2025-10-09T17:34:09.859Z" }, +] + [[package]] name = "markdown" version = "3.9" @@ -1330,6 +1379,103 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/e1/47887212baa7bc0532880d33d5eafbdb46fcc4b53789b903282a74a85b5b/openai-1.106.1-py3-none-any.whl", hash = "sha256:bfdef37c949f80396c59f2c17e0eda35414979bc07ef3379596a93c9ed044f3a", size = 930768, upload-time = "2025-09-04T18:17:13.349Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/04/05040d7ce33a907a2a02257e601992f0cdf11c73b33f13c4492bf6c3d6d5/opentelemetry_api-1.37.0.tar.gz", hash = "sha256:540735b120355bd5112738ea53621f8d5edb35ebcd6fe21ada3ab1c61d1cd9a7", size = 64923, upload-time = "2025-09-11T10:29:01.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/48/28ed9e55dcf2f453128df738210a980e09f4e468a456fa3c763dbc8be70a/opentelemetry_api-1.37.0-py3-none-any.whl", hash = "sha256:accf2024d3e89faec14302213bc39550ec0f4095d1cf5ca688e1bfb1c8612f47", size = 65732, upload-time = "2025-09-11T10:28:41.826Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6c/10018cbcc1e6fff23aac67d7fd977c3d692dbe5f9ef9bb4db5c1268726cc/opentelemetry_exporter_otlp_proto_common-1.37.0.tar.gz", hash = "sha256:c87a1bdd9f41fdc408d9cc9367bb53f8d2602829659f2b90be9f9d79d0bfe62c", size = 20430, upload-time = "2025-09-11T10:29:03.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/13/b4ef09837409a777f3c0af2a5b4ba9b7af34872bc43609dda0c209e4060d/opentelemetry_exporter_otlp_proto_common-1.37.0-py3-none-any.whl", hash = "sha256:53038428449c559b0c564b8d718df3314da387109c4d36bd1b94c9a641b0292e", size = 18359, upload-time = "2025-09-11T10:28:44.939Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/e3/6e320aeb24f951449e73867e53c55542bebbaf24faeee7623ef677d66736/opentelemetry_exporter_otlp_proto_http-1.37.0.tar.gz", hash = "sha256:e52e8600f1720d6de298419a802108a8f5afa63c96809ff83becb03f874e44ac", size = 17281, upload-time = "2025-09-11T10:29:04.844Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/e9/70d74a664d83976556cec395d6bfedd9b85ec1498b778367d5f93e373397/opentelemetry_exporter_otlp_proto_http-1.37.0-py3-none-any.whl", hash = "sha256:54c42b39945a6cc9d9a2a33decb876eabb9547e0dcb49df090122773447f1aef", size = 19576, upload-time = "2025-09-11T10:28:46.726Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.58b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/36/7c307d9be8ce4ee7beb86d7f1d31027f2a6a89228240405a858d6e4d64f9/opentelemetry_instrumentation-0.58b0.tar.gz", hash = "sha256:df640f3ac715a3e05af145c18f527f4422c6ab6c467e40bd24d2ad75a00cb705", size = 31549, upload-time = "2025-09-11T11:42:14.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/db/5ff1cd6c5ca1d12ecf1b73be16fbb2a8af2114ee46d4b0e6d4b23f4f4db7/opentelemetry_instrumentation-0.58b0-py3-none-any.whl", hash = "sha256:50f97ac03100676c9f7fc28197f8240c7290ca1baa12da8bfbb9a1de4f34cc45", size = 33019, upload-time = "2025-09-11T11:41:00.624Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/ea/a75f36b463a36f3c5a10c0b5292c58b31dbdde74f6f905d3d0ab2313987b/opentelemetry_proto-1.37.0.tar.gz", hash = "sha256:30f5c494faf66f77faeaefa35ed4443c5edb3b0aa46dad073ed7210e1a789538", size = 46151, upload-time = "2025-09-11T10:29:11.04Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/25/f89ea66c59bd7687e218361826c969443c4fa15dfe89733f3bf1e2a9e971/opentelemetry_proto-1.37.0-py3-none-any.whl", hash = "sha256:8ed8c066ae8828bbf0c39229979bdf583a126981142378a9cbe9d6fd5701c6e2", size = 72534, upload-time = "2025-09-11T10:28:56.831Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/62/2e0ca80d7fe94f0b193135375da92c640d15fe81f636658d2acf373086bc/opentelemetry_sdk-1.37.0.tar.gz", hash = "sha256:cc8e089c10953ded765b5ab5669b198bbe0af1b3f89f1007d19acd32dc46dda5", size = 170404, upload-time = "2025-09-11T10:29:11.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/62/9f4ad6a54126fb00f7ed4bb5034964c6e4f00fcd5a905e115bd22707e20d/opentelemetry_sdk-1.37.0-py3-none-any.whl", hash = "sha256:8f3c3c22063e52475c5dbced7209495c2c16723d016d39287dfc215d1771257c", size = 131941, upload-time = "2025-09-11T10:28:57.83Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.58b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/1b/90701d91e6300d9f2fb352153fb1721ed99ed1f6ea14fa992c756016e63a/opentelemetry_semantic_conventions-0.58b0.tar.gz", hash = "sha256:6bd46f51264279c433755767bb44ad00f1c9e2367e1b42af563372c5a6fa0c25", size = 129867, upload-time = "2025-09-11T10:29:12.597Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/90/68152b7465f50285d3ce2481b3aec2f82822e3f52e5152eeeaf516bab841/opentelemetry_semantic_conventions-0.58b0-py3-none-any.whl", hash = "sha256:5564905ab1458b96684db1340232729fce3b5375a06e140e8904c78e4f815b28", size = 207954, upload-time = "2025-09-11T10:28:59.218Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -1492,6 +1638,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, ] +[[package]] +name = "protobuf" +version = "6.32.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/a4/cc17347aa2897568beece2e674674359f911d6fe21b0b8d6268cd42727ac/protobuf-6.32.1.tar.gz", hash = "sha256:ee2469e4a021474ab9baafea6cd070e5bf27c7d29433504ddea1a4ee5850f68d", size = 440635, upload-time = "2025-09-11T21:38:42.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/98/645183ea03ab3995d29086b8bf4f7562ebd3d10c9a4b14ee3f20d47cfe50/protobuf-6.32.1-cp310-abi3-win32.whl", hash = "sha256:a8a32a84bc9f2aad712041b8b366190f71dde248926da517bde9e832e4412085", size = 424411, upload-time = "2025-09-11T21:38:27.427Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f3/6f58f841f6ebafe076cebeae33fc336e900619d34b1c93e4b5c97a81fdfa/protobuf-6.32.1-cp310-abi3-win_amd64.whl", hash = "sha256:b00a7d8c25fa471f16bc8153d0e53d6c9e827f0953f3c09aaa4331c718cae5e1", size = 435738, upload-time = "2025-09-11T21:38:30.959Z" }, + { url = "https://files.pythonhosted.org/packages/10/56/a8a3f4e7190837139e68c7002ec749190a163af3e330f65d90309145a210/protobuf-6.32.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8c7e6eb619ffdf105ee4ab76af5a68b60a9d0f66da3ea12d1640e6d8dab7281", size = 426454, upload-time = "2025-09-11T21:38:34.076Z" }, + { url = "https://files.pythonhosted.org/packages/3f/be/8dd0a927c559b37d7a6c8ab79034fd167dcc1f851595f2e641ad62be8643/protobuf-6.32.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:2f5b80a49e1eb7b86d85fcd23fe92df154b9730a725c3b38c4e43b9d77018bf4", size = 322874, upload-time = "2025-09-11T21:38:35.509Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f6/88d77011b605ef979aace37b7703e4eefad066f7e84d935e5a696515c2dd/protobuf-6.32.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:b1864818300c297265c83a4982fd3169f97122c299f56a56e2445c3698d34710", size = 322013, upload-time = "2025-09-11T21:38:37.017Z" }, + { url = "https://files.pythonhosted.org/packages/97/b7/15cc7d93443d6c6a84626ae3258a91f4c6ac8c0edd5df35ea7658f71b79c/protobuf-6.32.1-py3-none-any.whl", hash = "sha256:2601b779fc7d32a866c6b4404f9d42a3f67c5b9f3f15b4db3cccabe06b95c346", size = 169289, upload-time = "2025-09-11T21:38:41.234Z" }, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -2405,6 +2565,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, ] +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + [[package]] name = "yarl" version = "1.20.1" From d7636e9193ac2413b2c5c02f1b90588bca262b64 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 11 Oct 2025 05:24:43 +0800 Subject: [PATCH 6/6] fix: make check happy Signed-off-by: Chojan Shang --- .github/actions/setup-python-env/action.yml | 4 ++-- pyproject.toml | 2 +- uv.lock | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup-python-env/action.yml b/.github/actions/setup-python-env/action.yml index 7e8f6b3..206aa99 100644 --- a/.github/actions/setup-python-env/action.yml +++ b/.github/actions/setup-python-env/action.yml @@ -22,9 +22,9 @@ runs: uses: astral-sh/setup-uv@v6 with: version: ${{ inputs.uv-version }} - enable-cache: 'true' + enable-cache: "true" cache-suffix: ${{ matrix.python-version }} - name: Install Python dependencies - run: uv sync --frozen + run: uv sync --all-extras --all-groups --frozen shell: bash diff --git a/pyproject.toml b/pyproject.toml index a5d9435..097b3c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dev = [ ] [project.optional-dependencies] -logfire = ["logfire>=0.14"] +logfire = ["logfire>=0.14", "opentelemetry-sdk>=1.28.0"] [build-system] requires = ["hatchling"] diff --git a/uv.lock b/uv.lock index 7de1f2b..679436a 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ dependencies = [ [package.optional-dependencies] logfire = [ { name = "logfire" }, + { name = "opentelemetry-sdk" }, ] [package.dev-dependencies] @@ -35,6 +36,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "logfire", marker = "extra == 'logfire'", specifier = ">=0.14" }, + { name = "opentelemetry-sdk", marker = "extra == 'logfire'", specifier = ">=1.28.0" }, { name = "pydantic", specifier = ">=2.7" }, ] provides-extras = ["logfire"]