From e0560baf451146b0bd6bdaca09d628a4d95fa5db Mon Sep 17 00:00:00 2001 From: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:42:44 +0200 Subject: [PATCH 1/4] feat(schema): bump ACP schema to v1.19.0 (extensible unions + lenient deserialization) (#117) * feat(schema): support custom/future variants and bump ACP schema to v1.19.0 Bump schema-v1.16.0 -> schema-v1.19.0. v1.18.0 introduced an "extensible union" idiom (known const-tagged variants plus a "custom or future" catch-all member) that datamodel-codegen cannot express in a discriminated union, so it emitted broken placeholder literals. Codegen support (scripts/gen_schema.py): - _normalize_catchall_unions: strip the discriminator and collapse each catch-all member to a permissive object, so codegen emits a plain, payload-preserving union. - Inject a field_validator per catch-all that rejects the known discriminator values (recovered from the schema's `not` clause), so a malformed known variant fails instead of silently parsing as custom (mirrors the TS SDK's excludeKnownTags). - EXTENSIBLE_UNIONS map + drift assertion so changes to the union set fail loudly. - RENAME_MAP names for the 5 new variants; alias template updated so the parse adapters include the catch-all. Export the 3 new Elicitation*/Create* variants from the acp package. Add unit and behavioral regression tests. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe * feat(schema): honor x-deserialize-default-on-error and skip-invalid-items The schema declares lenient-deserialization hints that generated Pydantic models cannot express directly: x-deserialize-default-on-error (salvage a malformed field to its default) on 379 fields, and x-deserialize-skip-invalid-items (drop bad array items) on 35. Mirrors the TypeScript SDK's src/schema-deserialize.ts. - src/acp/_deserialize.py: leaf module (imports only pydantic, so schema.py can depend on it without a layering cycle) with salvage_on_error and skip_invalid_items. - gen_schema.py injects field_validator(mode="wrap") methods: `_meta` once on the shared BaseModel via check_fields=False (inherited everywhere); non-meta fields per $def grouped by fallback (None / [] / schema default); union-def common props target the member variant classes. - Generated schema.py uses an absolute import (from acp._deserialize) because gen_signature.py loads the module standalone, where relative imports cannot resolve. Add unit tests (fallback rules, spec extraction) and behavioral round-trips. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe * fix(client): reject unknown elicitation modes with a clean error Adding CreateOtherElicitationRequest to the CreateElicitationRequest union (so custom/future modes parse) made a previously-unreachable `raise TypeError` in the client router reachable: an incoming custom mode now parses, then _mode_from_create_elicitation_request hit the fallthrough and surfaced as an opaque -32603 internal error (plus a logged traceback). Raise RequestError.invalid_params instead, so a client that can't render an unknown mode declines with a clean -32602. Adds a regression test. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe * fix(schema): terminate generated JSON with a trailing newline pretty-format-json (pre-commit) rewrites schema/schema.json and schema/meta.json because gen_all wrote them via json.dumps(indent=2) with no trailing newline. Content is otherwise identical to the hook's output, so add the newline in download_schema (keeping regeneration idempotent) and to the committed files. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --------- Co-authored-by: Mistral Vibe --- schema/VERSION | 2 +- schema/schema.json | 1034 +++++++++++++++++----- scripts/gen_all.py | 4 +- scripts/gen_schema.py | 243 +++++- src/acp/__init__.py | 6 + src/acp/_deserialize.py | 43 + src/acp/client/router.py | 8 +- src/acp/meta.py | 2 +- src/acp/schema.py | 1294 +++++++++++++++++++++++----- tests/test_deserialize.py | 62 ++ tests/test_elicitation_catchall.py | 83 ++ tests/test_gen_all.py | 87 +- 12 files changed, 2391 insertions(+), 477 deletions(-) create mode 100644 src/acp/_deserialize.py create mode 100644 tests/test_deserialize.py create mode 100644 tests/test_elicitation_catchall.py diff --git a/schema/VERSION b/schema/VERSION index 994766d..5c3b96a 100644 --- a/schema/VERSION +++ b/schema/VERSION @@ -1 +1 @@ -refs/tags/schema-v1.16.0 +refs/tags/schema-v1.19.0 diff --git a/schema/schema.json b/schema/schema.json index a117ee8..0f44cbf 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -109,7 +109,7 @@ "anyOf": [ { "title": "CancelRequestNotification", - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or\nchanged at any point.\n\nCancels an ongoing request.\n\nThis is a notification sent by the side that sent a request to cancel that request.\n\nUpon receiving this notification, the receiver:\n\n1. MUST cancel the corresponding request activity and all nested activities\n2. MAY send any pending notifications.\n3. MUST send one of these responses for the original request:\n - Valid response with appropriate data (partial results or cancellation marker)\n - Error response with code `-32800` (Cancelled)\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/cancellation)", + "description": "Cancels an ongoing request.\n\nThis is a notification sent by the side that sent a request to cancel that request.\n\nUpon receiving this notification, the receiver:\n\n1. MAY cancel the corresponding request activity and all nested activities\n2. MAY send any pending notifications.\n3. MUST send one of these responses for the original request:\n - Valid response with appropriate data (partial results or cancellation marker)\n - Error response with code `-32800` (Cancelled)\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/cancellation)", "allOf": [ { "$ref": "#/$defs/CancelRequestNotification" @@ -332,6 +332,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -370,7 +371,8 @@ "null" ], "format": "uint32", - "minimum": 0 + "minimum": 0, + "x-deserialize-default-on-error": true }, "limit": { "description": "Maximum number of lines to read.", @@ -379,7 +381,8 @@ "null" ], "format": "uint32", - "minimum": 0 + "minimum": 0, + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -387,6 +390,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -430,6 +434,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -482,7 +487,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "content": { "description": "Replace the content collection.", @@ -509,10 +515,12 @@ "x-deserialize-skip-invalid-items": true }, "rawInput": { - "description": "Update the raw input." + "description": "Update the raw input.", + "x-deserialize-default-on-error": true }, "rawOutput": { - "description": "Update the raw output." + "description": "Update the raw output.", + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -520,6 +528,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -792,7 +801,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "priority": { "description": "Relative importance of this content when clients choose what to surface.", @@ -800,7 +810,8 @@ "number", "null" ], - "format": "double" + "format": "double", + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -808,6 +819,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -853,6 +865,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -889,7 +902,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -897,6 +911,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -935,6 +950,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -964,14 +980,16 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "mimeType": { "description": "MIME type describing the encoded media payload.", "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "name": { "description": "Human-readable name shown for this protocol object.", @@ -983,14 +1001,16 @@ "integer", "null" ], - "format": "int64" + "format": "int64", + "x-deserialize-default-on-error": true }, "title": { "description": "Optional display title for end-user UI.", "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "uri": { "description": "URI associated with this resource or media payload.", @@ -1002,6 +1022,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1042,7 +1063,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "text": { "description": "Text payload carried by this content block.", @@ -1058,6 +1080,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1079,7 +1102,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "uri": { "description": "URI associated with this resource or media payload.", @@ -1091,6 +1115,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1129,6 +1154,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1154,6 +1180,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1166,7 +1193,7 @@ "type": "object", "properties": { "path": { - "description": "The file path being modified.", + "description": "The absolute file path being modified.", "type": "string" }, "oldText": { @@ -1174,7 +1201,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "newText": { "description": "The new content after modification.", @@ -1186,6 +1214,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1216,6 +1245,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1228,7 +1258,7 @@ "type": "object", "properties": { "path": { - "description": "The file path being accessed or modified.", + "description": "The absolute file path being accessed or modified.", "type": "string" }, "line": { @@ -1238,7 +1268,8 @@ "null" ], "format": "uint32", - "minimum": 0 + "minimum": 0, + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -1246,6 +1277,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1283,6 +1315,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1342,21 +1375,26 @@ "type": "array", "items": { "type": "string" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "env": { "description": "Environment variables for the command.", "type": "array", "items": { "$ref": "#/$defs/EnvVariable" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "cwd": { - "description": "Working directory for the command (absolute path).", + "description": "Working directory for the command. Must be an absolute path.", "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "outputByteLimit": { "description": "Maximum number of output bytes to retain.\n\nWhen the limit is exceeded, the Client truncates from the beginning of the output\nto stay within the limit.\n\nThe Client MUST ensure truncation happens at a character boundary to maintain valid\nstring output, even if this means the retained output is slightly less than the\nspecified limit.", @@ -1365,7 +1403,8 @@ "null" ], "format": "uint64", - "minimum": 0 + "minimum": 0, + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -1373,6 +1412,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1401,6 +1441,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1435,6 +1476,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1471,6 +1513,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1507,6 +1550,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1543,6 +1587,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1567,10 +1612,11 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, - "oneOf": [ + "anyOf": [ { "description": "Form-based elicitation where the client renders a form from the provided schema.", "type": "object", @@ -1606,6 +1652,69 @@ "$ref": "#/$defs/ElicitationUrlMode" } ] + }, + { + "title": "other", + "description": "Custom or future elicitation mode.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nClients that do not understand this mode should preserve the raw payload\nwhen storing, replaying, proxying, or forwarding elicitation requests.\nThey MUST NOT render it as a known elicitation mode.", + "type": "object", + "properties": { + "mode": { + "description": "Custom or future elicitation mode.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": [ + "mode" + ], + "anyOf": [ + { + "title": "Session", + "description": "Tied to a session, optionally to a specific tool call within that session.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSessionScope" + } + ] + }, + { + "title": "Request", + "description": "Tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "allOf": [ + { + "$ref": "#/$defs/ElicitationRequestScope" + } + ] + } + ], + "unevaluatedProperties": true, + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "form" + } + }, + "required": [ + "mode" + ] + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "url" + } + }, + "required": [ + "mode" + ] + } + ] + } } ], "discriminator": { @@ -1638,7 +1747,8 @@ { "type": "null" } - ] + ], + "x-deserialize-default-on-error": true } }, "required": [ @@ -1668,6 +1778,7 @@ "properties": { "type": { "description": "Type discriminator. Always `\"object\"`.", + "x-deserialize-default-on-error": true, "default": "object", "allOf": [ { @@ -1680,7 +1791,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "properties": { "description": "Property definitions (must be primitive types).", @@ -1705,7 +1817,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -1713,6 +1826,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -1729,7 +1843,7 @@ }, "ElicitationPropertySchema": { "description": "Property schema for elicitation form fields.\n\nEach variant corresponds to a JSON Schema `\"type\"` value.\nSingle-select enums use the `String` variant with `enum` or `oneOf` set.\nMulti-select enums use the `Array` variant.", - "oneOf": [ + "anyOf": [ { "description": "String property (or single-select enum when `enum`/`oneOf` is set).", "type": "object", @@ -1819,6 +1933,85 @@ "$ref": "#/$defs/MultiSelectPropertySchema" } ] + }, + { + "title": "other", + "description": "Custom or future elicitation property schema.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nClients that do not understand this property schema type should preserve\nthe raw schema when storing, replaying, proxying, or forwarding\nelicitation requests. They MUST NOT render it as a known input control.", + "type": "object", + "properties": { + "type": { + "description": "Custom or future elicitation property schema type.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": [ + "type" + ], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "number" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "integer" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "boolean" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "array" + } + }, + "required": [ + "type" + ] + } + ] + }, + "additionalProperties": true } ], "discriminator": { @@ -1851,7 +2044,7 @@ ] }, "EnumOption": { - "description": "A titled enum option with a const value and human-readable title.", + "description": "A titled enum option with a const value, human-readable title, and optional description.", "type": "object", "properties": { "const": { @@ -1862,12 +2055,21 @@ "description": "Human-readable title for this option.", "type": "string" }, + "description": { + "description": "Human-readable description.", + "type": [ + "string", + "null" + ], + "x-deserialize-default-on-error": true + }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", "type": [ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -1885,14 +2087,16 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "description": { "description": "Human-readable description.", "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "minLength": { "description": "Minimum string length.", @@ -1935,7 +2139,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "enum": { "description": "Enum values for untitled single-select enums.", @@ -1963,6 +2168,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -1976,14 +2182,16 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "description": { "description": "Human-readable description.", "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "minimum": { "description": "Minimum value (inclusive).", @@ -2007,7 +2215,8 @@ "number", "null" ], - "format": "double" + "format": "double", + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -2015,6 +2224,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -2028,14 +2238,16 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "description": { "description": "Human-readable description.", "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "minimum": { "description": "Minimum value (inclusive).", @@ -2059,7 +2271,8 @@ "integer", "null" ], - "format": "int64" + "format": "int64", + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -2067,6 +2280,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -2080,21 +2294,24 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "description": { "description": "Human-readable description.", "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "default": { "description": "Default value.", "type": [ "boolean", "null" - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -2102,6 +2319,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -2110,16 +2328,56 @@ "description": "Items for a multi-select (array) property schema.", "anyOf": [ { - "title": "Untitled", - "description": "Untitled multi-select items with plain string values.", + "description": "Multi-select string items with plain string values.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": [ + "type" + ], "allOf": [ { - "$ref": "#/$defs/UntitledMultiSelectItems" + "$ref": "#/$defs/StringMultiSelectItems" } ] }, { - "title": "Titled", + "title": "other", + "description": "Custom or future typed multi-select items.", + "type": "object", + "properties": { + "type": { + "description": "Custom or future multi-select item type.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": [ + "type" + ], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": [ + "type" + ] + } + ] + }, + "additionalProperties": true + }, + { + "title": "titled", "description": "Titled multi-select items with human-readable labels.", "allOf": [ { @@ -2127,20 +2385,15 @@ } ] } - ] + ], + "discriminator": { + "propertyName": "type" + } }, - "UntitledMultiSelectItems": { - "description": "Items definition for untitled multi-select enum properties.", + "StringMultiSelectItems": { + "description": "String item schema for multi-select enum properties.", "type": "object", "properties": { - "type": { - "description": "Item type discriminator. Must be `\"string\"`.", - "allOf": [ - { - "$ref": "#/$defs/ElicitationStringType" - } - ] - }, "enum": { "description": "Allowed enum values.", "type": "array", @@ -2154,24 +2407,14 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, "required": [ - "type", "enum" ] }, - "ElicitationStringType": { - "description": "Items definition for untitled multi-select enum properties.", - "oneOf": [ - { - "description": "String schema type.", - "type": "string", - "const": "string" - } - ] - }, "TitledMultiSelectItems": { "description": "Items definition for titled multi-select enum properties.", "type": "object", @@ -2189,6 +2432,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -2205,14 +2449,16 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "description": { "description": "Human-readable description.", "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "minItems": { "description": "Minimum number of items to select.", @@ -2248,7 +2494,9 @@ ], "items": { "type": "string" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -2256,6 +2504,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -2351,7 +2600,7 @@ "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for `mcp/connect`.", "type": "object", "properties": { - "acpId": { + "serverId": { "description": "The ACP MCP server ID that was provided by the component declaring the MCP server.", "allOf": [ { @@ -2365,11 +2614,12 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, "required": [ - "acpId" + "serverId" ], "x-side": "client", "x-method": "mcp/connect" @@ -2408,6 +2658,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -2440,6 +2691,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -2712,6 +2964,7 @@ }, "agentCapabilities": { "description": "Capabilities supported by the agent.", + "x-deserialize-default-on-error": true, "default": { "loadSession": false, "promptCapabilities": { @@ -2761,6 +3014,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -2784,10 +3038,12 @@ "loadSession": { "description": "Whether the agent supports `session/load`.", "type": "boolean", - "default": false + "default": false, + "x-deserialize-default-on-error": true }, "promptCapabilities": { "description": "Prompt capabilities supported by the agent.", + "x-deserialize-default-on-error": true, "default": { "image": false, "audio": false, @@ -2801,6 +3057,7 @@ }, "mcpCapabilities": { "description": "MCP capabilities supported by the agent.", + "x-deserialize-default-on-error": true, "default": { "http": false, "sse": false, @@ -2814,6 +3071,7 @@ }, "sessionCapabilities": { "description": "Session lifecycle and prompt capabilities advertised by the agent.", + "x-deserialize-default-on-error": true, "default": {}, "allOf": [ { @@ -2823,6 +3081,7 @@ }, "auth": { "description": "Authentication-related capabilities supported by the agent.", + "x-deserialize-default-on-error": true, "default": {}, "allOf": [ { @@ -2831,7 +3090,7 @@ ] }, "providers": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nProvider configuration capabilities supported by the agent.\n\nBy supplying `{}` it means that the agent supports provider configuration methods.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nProvider configuration capabilities supported by the agent.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports provider configuration methods.", "anyOf": [ { "$ref": "#/$defs/ProvidersCapabilities" @@ -2843,7 +3102,7 @@ "x-deserialize-default-on-error": true }, "nes": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNES (Next Edit Suggestions) capabilities supported by the agent.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNES (Next Edit Suggestions) capabilities supported by the agent.\n\nOptional. Omitted or `null` both mean the agent does not advertise support\nfor NES methods.", "anyOf": [ { "$ref": "#/$defs/NesCapabilities" @@ -2872,6 +3131,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -2883,17 +3143,20 @@ "image": { "description": "Agent supports [`ContentBlock::Image`].", "type": "boolean", - "default": false + "default": false, + "x-deserialize-default-on-error": true }, "audio": { "description": "Agent supports [`ContentBlock::Audio`].", "type": "boolean", - "default": false + "default": false, + "x-deserialize-default-on-error": true }, "embeddedContext": { "description": "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.", "type": "boolean", - "default": false + "default": false, + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -2901,6 +3164,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -2912,17 +3176,20 @@ "http": { "description": "Agent supports [`McpServer::Http`].", "type": "boolean", - "default": false + "default": false, + "x-deserialize-default-on-error": true }, "sse": { "description": "Agent supports [`McpServer::Sse`].", "type": "boolean", - "default": false + "default": false, + "x-deserialize-default-on-error": true }, "acp": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAgent supports [`McpServer::Acp`].", "type": "boolean", - "default": false + "default": false, + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -2930,6 +3197,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -2939,7 +3207,7 @@ "type": "object", "properties": { "list": { - "description": "Whether the agent supports `session/list`.", + "description": "Whether the agent supports `session/list`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports listing sessions.", "anyOf": [ { "$ref": "#/$defs/SessionListCapabilities" @@ -2963,7 +3231,7 @@ "x-deserialize-default-on-error": true }, "additionalDirectories": { - "description": "Whether the agent supports `additionalDirectories` on supported session lifecycle requests.\n\nAgents that also support `session/list` may return\n`SessionInfo.additionalDirectories` to report the complete ordered\nadditional-root list associated with a listed session.", + "description": "Whether the agent supports `additionalDirectories` on supported session lifecycle requests.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports `additionalDirectories` on\nsupported session lifecycle requests.\n\nAgents that also support `session/list` may return\n`SessionInfo.additionalDirectories` to report the complete ordered\nadditional-root list associated with a listed session.", "anyOf": [ { "$ref": "#/$defs/SessionAdditionalDirectoriesCapabilities" @@ -2975,7 +3243,7 @@ "x-deserialize-default-on-error": true }, "fork": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/fork`.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/fork`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports forking sessions.", "anyOf": [ { "$ref": "#/$defs/SessionForkCapabilities" @@ -2987,7 +3255,7 @@ "x-deserialize-default-on-error": true }, "resume": { - "description": "Whether the agent supports `session/resume`.", + "description": "Whether the agent supports `session/resume`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports resuming sessions.", "anyOf": [ { "$ref": "#/$defs/SessionResumeCapabilities" @@ -2999,7 +3267,7 @@ "x-deserialize-default-on-error": true }, "close": { - "description": "Whether the agent supports `session/close`.", + "description": "Whether the agent supports `session/close`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports closing sessions.", "anyOf": [ { "$ref": "#/$defs/SessionCloseCapabilities" @@ -3016,12 +3284,13 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } }, "SessionListCapabilities": { - "description": "Capabilities for the `session/list` method.\n\nBy supplying `{}` it means that the agent supports listing of sessions.", + "description": "Capabilities for the `session/list` method.\n\nSupplying `{}` means the agent supports listing sessions.", "type": "object", "properties": { "_meta": { @@ -3030,6 +3299,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3044,12 +3314,13 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } }, "SessionAdditionalDirectoriesCapabilities": { - "description": "Capabilities for additional session directories support.\n\nBy supplying `{}` it means that the agent supports the `additionalDirectories`\nfield on supported session lifecycle requests. Agents that also support\n`session/list` may return `SessionInfo.additionalDirectories` to report the\ncomplete ordered additional-root list associated with a listed session.", + "description": "Capabilities for additional session directories support.\n\nSupplying `{}` means the agent supports the `additionalDirectories` field on\nsupported session lifecycle requests. Agents that also support\n`session/list` may return `SessionInfo.additionalDirectories` to report the\ncomplete ordered additional-root list associated with a listed session.", "type": "object", "properties": { "_meta": { @@ -3058,12 +3329,13 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } }, "SessionForkCapabilities": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for the `session/fork` method.\n\nBy supplying `{}` it means that the agent supports forking of sessions.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for the `session/fork` method.\n\nSupplying `{}` means the agent supports forking sessions.", "type": "object", "properties": { "_meta": { @@ -3072,12 +3344,13 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } }, "SessionResumeCapabilities": { - "description": "Capabilities for the `session/resume` method.\n\nBy supplying `{}` it means that the agent supports resuming of sessions.", + "description": "Capabilities for the `session/resume` method.\n\nSupplying `{}` means the agent supports resuming sessions.", "type": "object", "properties": { "_meta": { @@ -3086,12 +3359,13 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } }, "SessionCloseCapabilities": { - "description": "Capabilities for the `session/close` method.\n\nBy supplying `{}` it means that the agent supports closing of sessions.", + "description": "Capabilities for the `session/close` method.\n\nSupplying `{}` means the agent supports closing sessions.", "type": "object", "properties": { "_meta": { @@ -3100,6 +3374,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3109,7 +3384,7 @@ "type": "object", "properties": { "logout": { - "description": "Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method.", + "description": "Whether the agent supports the logout method.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports the logout method.", "anyOf": [ { "$ref": "#/$defs/LogoutCapabilities" @@ -3126,12 +3401,13 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } }, "LogoutCapabilities": { - "description": "Logout capabilities supported by the agent.\n\nBy supplying `{}` it means that the agent supports the logout method.", + "description": "Logout capabilities supported by the agent.\n\nSupplying `{}` means the agent supports the logout method.", "type": "object", "properties": { "_meta": { @@ -3140,12 +3416,13 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } }, "ProvidersCapabilities": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nProvider configuration capabilities supported by the agent.\n\nBy supplying `{}` it means that the agent supports provider configuration methods.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nProvider configuration capabilities supported by the agent.\n\nSupplying `{}` means the agent supports provider configuration methods.", "type": "object", "properties": { "_meta": { @@ -3154,6 +3431,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3192,6 +3470,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3218,6 +3497,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3292,6 +3572,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3306,6 +3587,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3328,6 +3610,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -3360,6 +3643,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3374,6 +3658,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3388,6 +3673,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3474,6 +3760,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3489,7 +3776,8 @@ "null" ], "format": "uint32", - "minimum": 0 + "minimum": 0, + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -3497,6 +3785,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3511,6 +3800,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3526,7 +3816,8 @@ "null" ], "format": "uint32", - "minimum": 0 + "minimum": 0, + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -3534,6 +3825,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3549,7 +3841,8 @@ "null" ], "format": "uint32", - "minimum": 0 + "minimum": 0, + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -3557,6 +3850,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3571,6 +3865,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3585,6 +3880,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -3676,16 +3972,19 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "secret": { "description": "Whether this value is a secret (e.g. API key, token).\nClients should use a password-style input for secret vars.\n\nDefaults to `true`.", "type": "boolean", + "x-deserialize-default-on-error": true, "default": true }, "optional": { "description": "Whether this variable is optional.\n\nDefaults to `false`.", "type": "boolean", + "x-deserialize-default-on-error": true, "default": false }, "_meta": { @@ -3694,6 +3993,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -3722,21 +4022,25 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "vars": { "description": "The environment variables the client should set.", "type": "array", "items": { "$ref": "#/$defs/AuthEnvVar" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "link": { "description": "Optional link to a page where the user can obtain their credentials.", "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -3744,6 +4048,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -3774,18 +4079,22 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "args": { "description": "Additional arguments to pass when running the agent binary for terminal auth.", "type": "array", "items": { "type": "string" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "env": { "description": "Additional environment variables to set when running the agent binary for terminal auth.", "type": "object", + "x-deserialize-default-on-error": true, "additionalProperties": { "type": "string" } @@ -3796,6 +4105,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -3825,7 +4135,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -3833,6 +4144,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -3842,7 +4154,7 @@ ] }, "Implementation": { - "description": "Metadata about the implementation of the client or agent.\nDescribes the name and version of an MCP implementation, with an optional\ntitle for UI representation.", + "description": "Metadata about the implementation of the client or agent.\nDescribes the name and version of an ACP implementation, with an optional\ntitle for UI representation.", "type": "object", "properties": { "name": { @@ -3854,7 +4166,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "version": { "description": "Version of the implementation. Can be displayed to the user or used\nfor debugging or metrics purposes. (e.g. \"1.0.0\").", @@ -3866,6 +4179,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -3884,6 +4198,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -3899,9 +4214,7 @@ "type": "array", "items": { "$ref": "#/$defs/ProviderInfo" - }, - "x-deserialize-default-on-error": true, - "x-deserialize-skip-invalid-items": true + } }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -3909,6 +4222,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -3922,9 +4236,13 @@ "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nInformation about a configurable LLM provider.", "type": "object", "properties": { - "id": { + "providerId": { "description": "Provider identifier, for example \"main\" or \"openai\".", - "type": "string" + "allOf": [ + { + "$ref": "#/$defs/ProviderId" + } + ] }, "supported": { "description": "Supported protocol types for this provider.", @@ -3936,7 +4254,7 @@ "x-deserialize-skip-invalid-items": true }, "required": { - "description": "Whether this provider is mandatory and cannot be disabled via `providers/disable`.\nIf true, clients must not call `providers/disable` for this id.", + "description": "Whether this provider is mandatory and cannot be disabled via `providers/disable`.\nIf true, clients must not call `providers/disable` for this provider ID.", "type": "boolean" }, "current": { @@ -3956,15 +4274,20 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, "required": [ - "id", + "providerId", "supported", "required" ] }, + "ProviderId": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUnique identifier for a configurable LLM provider.", + "type": "string" + }, "LlmProtocol": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWell-known API protocol identifiers for LLM providers.\n\nAgents and clients MUST handle unknown protocol identifiers gracefully.\n\nProtocol names beginning with `_` are free for custom use, like other ACP extension methods.\nProtocol names that do not begin with `_` are reserved for the ACP spec.", "anyOf": [ @@ -4022,6 +4345,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4040,6 +4364,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4056,6 +4381,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4072,6 +4398,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4120,6 +4447,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4156,6 +4484,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4189,7 +4518,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -4197,6 +4527,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4226,7 +4557,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "category": { "description": "Optional semantic category for this option (UX only).", @@ -4246,6 +4578,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4273,7 +4606,7 @@ ] }, { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nBoolean on/off toggle.", + "description": "Boolean on/off toggle.", "type": "object", "properties": { "type": { @@ -4375,7 +4708,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -4383,6 +4717,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4412,7 +4747,9 @@ "type": "array", "items": { "$ref": "#/$defs/SessionConfigSelectOption" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -4420,6 +4757,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4460,7 +4798,7 @@ ] }, "SessionConfigBoolean": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA boolean on/off toggle session configuration option payload.", + "description": "A boolean on/off toggle session configuration option payload.", "type": "object", "properties": { "currentValue": { @@ -4506,6 +4844,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4530,7 +4869,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -4538,6 +4878,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4568,7 +4909,9 @@ "type": "array", "items": { "type": "string" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "title": { "description": "Human-readable title for the session", @@ -4592,6 +4935,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4610,6 +4954,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4658,6 +5003,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4701,6 +5047,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4717,6 +5064,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4733,6 +5081,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4758,6 +5107,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4797,6 +5147,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4865,7 +5216,8 @@ "null" ], "format": "uint64", - "minimum": 0 + "minimum": 0, + "x-deserialize-default-on-error": true }, "cachedReadTokens": { "description": "Total cache read tokens.", @@ -4874,7 +5226,8 @@ "null" ], "format": "uint64", - "minimum": 0 + "minimum": 0, + "x-deserialize-default-on-error": true }, "cachedWriteTokens": { "description": "Total cache write tokens.", @@ -4883,7 +5236,8 @@ "null" ], "format": "uint64", - "minimum": 0 + "minimum": 0, + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -4891,6 +5245,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4918,6 +5273,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -4936,9 +5292,7 @@ "type": "array", "items": { "$ref": "#/$defs/NesSuggestion" - }, - "x-deserialize-default-on-error": true, - "x-deserialize-skip-invalid-items": true + } }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -4946,6 +5300,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5035,6 +5390,10 @@ "propertyName": "kind" } }, + "NesSuggestionId": { + "description": "Unique identifier for a next edit suggestion.", + "type": "string" + }, "NesTextEdit": { "description": "A text edit within a suggestion.", "type": "object", @@ -5057,6 +5416,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5091,6 +5451,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5121,6 +5482,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5135,7 +5497,11 @@ "properties": { "id": { "description": "Unique identifier for accept/reject tracking.", - "type": "string" + "allOf": [ + { + "$ref": "#/$defs/NesSuggestionId" + } + ] }, "uri": { "description": "The URI of the file to edit.", @@ -5166,6 +5532,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5181,7 +5548,11 @@ "properties": { "id": { "description": "Unique identifier for accept/reject tracking.", - "type": "string" + "allOf": [ + { + "$ref": "#/$defs/NesSuggestionId" + } + ] }, "uri": { "description": "The file to navigate to.", @@ -5201,6 +5572,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5216,7 +5588,11 @@ "properties": { "id": { "description": "Unique identifier for accept/reject tracking.", - "type": "string" + "allOf": [ + { + "$ref": "#/$defs/NesSuggestionId" + } + ] }, "uri": { "description": "The file URI containing the symbol.", @@ -5240,6 +5616,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5256,7 +5633,11 @@ "properties": { "id": { "description": "Unique identifier for accept/reject tracking.", - "type": "string" + "allOf": [ + { + "$ref": "#/$defs/NesSuggestionId" + } + ] }, "uri": { "description": "The file URI to search within.", @@ -5283,6 +5664,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5303,6 +5685,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5334,7 +5717,8 @@ "type": "string" }, "data": { - "description": "Optional primitive or structured value that contains additional information about the error.\nThis may include debugging information or context-specific details." + "description": "Optional primitive or structured value that contains additional information about the error.\nThis may include debugging information or context-specific details.", + "x-deserialize-default-on-error": true } }, "required": [ @@ -5382,7 +5766,7 @@ }, { "title": "Request cancelled", - "description": "**Request cancelled**: **UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nExecution of the method was aborted either due to a cancellation request from the caller or\nbecause of resource constraints or shutdown.", + "description": "**Request cancelled**: Execution of the method was aborted either due to a cancellation request from the caller or\nbecause of resource constraints or shutdown.", "type": "integer", "format": "int32", "const": -32800 @@ -5401,13 +5785,6 @@ "format": "int32", "const": -32002 }, - { - "title": "URL elicitation required", - "description": "**URL elicitation required**: **UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nThe agent requires user input via a URL-based elicitation before it can proceed.", - "type": "integer", - "format": "int32", - "const": -32042 - }, { "title": "Other", "description": "Other undefined error code.", @@ -5505,6 +5882,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5782,7 +6160,8 @@ { "type": "null" } - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -5790,6 +6169,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5815,6 +6195,7 @@ }, "kind": { "description": "The category of tool being invoked.\nHelps clients choose appropriate icons and UI treatment.", + "x-deserialize-default-on-error": true, "allOf": [ { "$ref": "#/$defs/ToolKind" @@ -5823,6 +6204,7 @@ }, "status": { "description": "Current execution status of the tool call.", + "x-deserialize-default-on-error": true, "allOf": [ { "$ref": "#/$defs/ToolCallStatus" @@ -5848,10 +6230,12 @@ "x-deserialize-skip-invalid-items": true }, "rawInput": { - "description": "Raw input parameters sent to the tool." + "description": "Raw input parameters sent to the tool.", + "x-deserialize-default-on-error": true }, "rawOutput": { - "description": "Raw output returned by the tool." + "description": "Raw output returned by the tool.", + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -5859,6 +6243,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5897,6 +6282,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -5965,6 +6351,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -6042,7 +6429,7 @@ "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA plan represented as structured entries.", "type": "object", "properties": { - "id": { + "planId": { "description": "The plan ID to update.", "allOf": [ { @@ -6065,11 +6452,12 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, "required": [ - "id", + "planId", "entries" ] }, @@ -6077,7 +6465,7 @@ "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA plan represented by a file URI.", "type": "object", "properties": { - "id": { + "planId": { "description": "The plan ID to update.", "allOf": [ { @@ -6095,11 +6483,12 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, "required": [ - "id", + "planId", "uri" ] }, @@ -6107,7 +6496,7 @@ "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA plan represented as raw markdown content.", "type": "object", "properties": { - "id": { + "planId": { "description": "The plan ID to update.", "allOf": [ { @@ -6125,11 +6514,12 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, "required": [ - "id", + "planId", "content" ] }, @@ -6151,6 +6541,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -6162,7 +6553,7 @@ "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRemoval notice for a plan identified by ID.", "type": "object", "properties": { - "id": { + "planId": { "description": "The plan ID to remove.", "allOf": [ { @@ -6176,11 +6567,12 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, "required": [ - "id" + "planId" ] }, "AvailableCommand": { @@ -6213,6 +6605,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -6249,6 +6642,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -6275,6 +6669,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -6300,6 +6695,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -6326,6 +6722,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -6342,14 +6739,16 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "updatedAt": { "description": "ISO 8601 timestamp of last activity. Set to null to clear.", "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -6357,6 +6756,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -6380,6 +6780,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -6422,6 +6823,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -6448,6 +6850,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -6479,6 +6882,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true }, "_meta": { @@ -6487,6 +6891,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -6739,6 +7144,7 @@ }, "clientCapabilities": { "description": "Capabilities supported by the client.", + "x-deserialize-default-on-error": true, "default": { "fs": { "readTextFile": false, @@ -6773,6 +7179,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -6788,6 +7195,7 @@ "properties": { "fs": { "description": "File system capabilities supported by the client.\nDetermines which file operations the agent can request.", + "x-deserialize-default-on-error": true, "default": { "readTextFile": false, "writeTextFile": false @@ -6801,10 +7209,11 @@ "terminal": { "description": "Whether the Client support all `terminal/*` methods.", "type": "boolean", - "default": false + "default": false, + "x-deserialize-default-on-error": true }, "session": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nSession-related capabilities supported by the client.", + "description": "Session-related capabilities supported by the client.\n\nOptional. Omitted or `null` both mean the client does not advertise any\nsession-related extensions.", "anyOf": [ { "$ref": "#/$defs/ClientSessionCapabilities" @@ -6816,7 +7225,7 @@ "x-deserialize-default-on-error": true }, "plan": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client supports `plan_update` and `plan_removed` session updates.\n\nOptional. Omitted means the client does not advertise support.\nSupplying `{}` means the client can receive both update types.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client supports `plan_update` and `plan_removed` session updates.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means the client can receive both update types.", "anyOf": [ { "$ref": "#/$defs/PlanCapabilities" @@ -6829,6 +7238,7 @@ }, "auth": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAuthentication capabilities supported by the client.\nDetermines which authentication method types the agent may include\nin its `InitializeResponse`.", + "x-deserialize-default-on-error": true, "default": { "terminal": false }, @@ -6839,7 +7249,7 @@ ] }, "elicitation": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use.\n\nOptional. Omitted or `null` both mean the client does not advertise\nelicitation support.", "anyOf": [ { "$ref": "#/$defs/ElicitationCapabilities" @@ -6851,7 +7261,7 @@ "x-deserialize-default-on-error": true }, "nes": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNES (Next Edit Suggestions) capabilities supported by the client.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNES (Next Edit Suggestions) capabilities supported by the client.\n\nOptional. Omitted or `null` both mean the client does not advertise any\nNES suggestion-kind extensions.", "anyOf": [ { "$ref": "#/$defs/ClientNesCapabilities" @@ -6877,6 +7287,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -6888,12 +7299,14 @@ "readTextFile": { "description": "Whether the Client supports `fs/read_text_file` requests.", "type": "boolean", - "default": false + "default": false, + "x-deserialize-default-on-error": true }, "writeTextFile": { "description": "Whether the Client supports `fs/write_text_file` requests.", "type": "boolean", - "default": false + "default": false, + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -6901,16 +7314,17 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } }, "ClientSessionCapabilities": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nSession-related capabilities supported by the client.", + "description": "Session-related capabilities supported by the client.", "type": "object", "properties": { "configOptions": { - "description": "Config option capabilities supported by the client.\n\nOmitted or `null` means the client does not advertise support for any\nconfig option extensions.", + "description": "Config option capabilities supported by the client.\n\nOmitted or `null` both mean the client does not advertise support for any\nconfig option extensions.", "anyOf": [ { "$ref": "#/$defs/SessionConfigOptionsCapabilities" @@ -6927,16 +7341,17 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } }, "SessionConfigOptionsCapabilities": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nSession configuration option capabilities supported by the client.", + "description": "Session configuration option capabilities supported by the client.", "type": "object", "properties": { "boolean": { - "description": "Whether the client supports boolean session configuration options.\n\nOmitted or `null` means the client does not advertise support.\nSupplying `{}` means agents may include `type: \"boolean\"` entries in\n`configOptions`, and the client may send `session/set_config_option`\nrequests with `type: \"boolean\"` and a boolean `value`.", + "description": "Whether the client supports boolean session configuration options.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means agents may include `type: \"boolean\"` entries in\n`configOptions`, and the client may send `session/set_config_option`\nrequests with `type: \"boolean\"` and a boolean `value`.", "anyOf": [ { "$ref": "#/$defs/BooleanConfigOptionCapabilities" @@ -6953,12 +7368,13 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } }, "BooleanConfigOptionCapabilities": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for boolean session configuration options.\n\nSupplying `{}` means the client supports boolean session configuration options.", + "description": "Capabilities for boolean session configuration options.\n\nSupplying `{}` means the client supports boolean session configuration options.", "type": "object", "properties": { "_meta": { @@ -6967,6 +7383,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -6981,6 +7398,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -6992,7 +7410,8 @@ "terminal": { "description": "Whether the client supports `terminal` authentication methods.\n\nWhen `true`, the agent may include `terminal` entries in its authentication methods.", "type": "boolean", - "default": false + "default": false, + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -7000,6 +7419,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -7009,7 +7429,7 @@ "type": "object", "properties": { "form": { - "description": "Whether the client supports form-based elicitation.", + "description": "Whether the client supports form-based elicitation.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means the client supports form-based elicitation.", "anyOf": [ { "$ref": "#/$defs/ElicitationFormCapabilities" @@ -7021,7 +7441,7 @@ "x-deserialize-default-on-error": true }, "url": { - "description": "Whether the client supports URL-based elicitation.", + "description": "Whether the client supports URL-based elicitation.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means the client supports URL-based elicitation.", "anyOf": [ { "$ref": "#/$defs/ElicitationUrlCapabilities" @@ -7038,12 +7458,13 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } }, "ElicitationFormCapabilities": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation capabilities.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation capabilities.\n\nSupplying `{}` means the client supports form-based elicitation.", "type": "object", "properties": { "_meta": { @@ -7052,12 +7473,13 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } }, "ElicitationUrlCapabilities": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nURL-based elicitation capabilities.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nURL-based elicitation capabilities.\n\nSupplying `{}` means the client supports URL-based elicitation.", "type": "object", "properties": { "_meta": { @@ -7066,6 +7488,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -7116,6 +7539,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -7130,6 +7554,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -7144,6 +7569,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -7158,6 +7584,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -7180,6 +7607,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7199,6 +7627,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7206,12 +7635,16 @@ "x-method": "providers/list" }, "SetProviderRequest": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for `providers/set`.\n\nReplaces the full configuration for one provider id.", + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for `providers/set`.\n\nReplaces the full configuration for one provider ID.", "type": "object", "properties": { - "id": { - "description": "Provider id to configure.", - "type": "string" + "providerId": { + "description": "Provider ID to configure.", + "allOf": [ + { + "$ref": "#/$defs/ProviderId" + } + ] }, "apiType": { "description": "Protocol type for this provider.", @@ -7238,11 +7671,12 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, "required": [ - "id", + "providerId", "apiType", "baseUrl" ], @@ -7253,9 +7687,13 @@ "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nRequest parameters for `providers/disable`.", "type": "object", "properties": { - "id": { - "description": "Provider id to disable.", - "type": "string" + "providerId": { + "description": "Provider ID to disable.", + "allOf": [ + { + "$ref": "#/$defs/ProviderId" + } + ] }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -7263,11 +7701,12 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, "required": [ - "id" + "providerId" ], "x-side": "agent", "x-method": "providers/disable" @@ -7282,6 +7721,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7301,14 +7741,18 @@ "type": "array", "items": { "type": "string" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "mcpServers": { "description": "List of MCP (Model Context Protocol) servers the agent should connect to.", "type": "array", "items": { "$ref": "#/$defs/McpServer" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -7316,6 +7760,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7412,6 +7857,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7445,6 +7891,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7479,6 +7926,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7496,7 +7944,7 @@ "description": "Human-readable name identifying this MCP server.", "type": "string" }, - "id": { + "serverId": { "description": "Unique identifier for this MCP server, generated by the component providing it.\n\nProviders MUST NOT reuse an ID for multiple ACP-transport MCP servers that are visible\non the same ACP connection.", "allOf": [ { @@ -7510,12 +7958,13 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, "required": [ "name", - "id" + "serverId" ] }, "McpServerStdio": { @@ -7527,7 +7976,7 @@ "type": "string" }, "command": { - "description": "Path to the MCP server executable.", + "description": "Absolute path to the MCP server executable.", "type": "string" }, "args": { @@ -7550,6 +7999,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7569,10 +8019,12 @@ "type": "array", "items": { "$ref": "#/$defs/McpServer" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "cwd": { - "description": "The working directory for this session.", + "description": "The working directory for this session. Must be an absolute path.", "type": "string" }, "additionalDirectories": { @@ -7580,7 +8032,9 @@ "type": "array", "items": { "type": "string" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "sessionId": { "description": "The ID of the session to load.", @@ -7596,6 +8050,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7631,6 +8086,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7655,6 +8111,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7677,7 +8134,7 @@ ] }, "cwd": { - "description": "The working directory for this session.", + "description": "The working directory for this session. Must be an absolute path.", "type": "string" }, "additionalDirectories": { @@ -7685,14 +8142,18 @@ "type": "array", "items": { "type": "string" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "mcpServers": { "description": "List of MCP servers to connect to for this session.", "type": "array", "items": { "$ref": "#/$defs/McpServer" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -7700,6 +8161,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7723,7 +8185,7 @@ ] }, "cwd": { - "description": "The working directory for this session.", + "description": "The working directory for this session. Must be an absolute path.", "type": "string" }, "additionalDirectories": { @@ -7731,14 +8193,18 @@ "type": "array", "items": { "type": "string" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "mcpServers": { "description": "List of MCP servers to connect to for this session.", "type": "array", "items": { "$ref": "#/$defs/McpServer" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -7746,6 +8212,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7774,6 +8241,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7809,6 +8277,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7845,6 +8314,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7918,6 +8388,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7937,7 +8408,8 @@ "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "workspaceFolders": { "description": "The workspace folders.", @@ -7947,9 +8419,7 @@ ], "items": { "$ref": "#/$defs/WorkspaceFolder" - }, - "x-deserialize-default-on-error": true, - "x-deserialize-skip-invalid-items": true + } }, "repository": { "description": "Repository metadata, if the workspace is a git repository.", @@ -7969,6 +8439,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -7993,6 +8464,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8023,6 +8495,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8070,8 +8543,7 @@ { "type": "null" } - ], - "x-deserialize-default-on-error": true + ] }, "triggerKind": { "description": "What triggered this suggestion request.", @@ -8090,8 +8562,7 @@ { "type": "null" } - ], - "x-deserialize-default-on-error": true + ] }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -8099,6 +8570,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8144,9 +8616,7 @@ ], "items": { "$ref": "#/$defs/NesRecentFile" - }, - "x-deserialize-default-on-error": true, - "x-deserialize-skip-invalid-items": true + } }, "relatedSnippets": { "description": "Related code snippets.", @@ -8156,9 +8626,7 @@ ], "items": { "$ref": "#/$defs/NesRelatedSnippet" - }, - "x-deserialize-default-on-error": true, - "x-deserialize-skip-invalid-items": true + } }, "editHistory": { "description": "Recent edit history.", @@ -8168,9 +8636,7 @@ ], "items": { "$ref": "#/$defs/NesEditHistoryEntry" - }, - "x-deserialize-default-on-error": true, - "x-deserialize-skip-invalid-items": true + } }, "userActions": { "description": "Recent user actions (typing, navigation, etc.).", @@ -8180,9 +8646,7 @@ ], "items": { "$ref": "#/$defs/NesUserAction" - }, - "x-deserialize-default-on-error": true, - "x-deserialize-skip-invalid-items": true + } }, "openFiles": { "description": "Currently open files in the editor.", @@ -8192,9 +8656,7 @@ ], "items": { "$ref": "#/$defs/NesOpenFile" - }, - "x-deserialize-default-on-error": true, - "x-deserialize-skip-invalid-items": true + } }, "diagnostics": { "description": "Current diagnostics (errors, warnings).", @@ -8204,9 +8666,7 @@ ], "items": { "$ref": "#/$defs/NesDiagnostic" - }, - "x-deserialize-default-on-error": true, - "x-deserialize-skip-invalid-items": true + } }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -8214,6 +8674,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -8240,6 +8701,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8270,6 +8732,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8304,6 +8767,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8331,6 +8795,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8371,6 +8836,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8421,6 +8887,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8463,6 +8930,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8516,6 +8984,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8644,20 +9113,20 @@ ] }, { - "title": "ExtMethodResponse", - "description": "Successful result returned by an extension method outside the core ACP method set.", + "title": "MessageMcpResponse", + "description": "Successful result returned by an MCP-over-ACP `mcp/message` request.", "allOf": [ { - "$ref": "#/$defs/ExtResponse" + "$ref": "#/$defs/MessageMcpResponse" } ] }, { - "title": "MessageMcpResponse", - "description": "Successful result returned by an MCP-over-ACP `mcp/message` request.", + "title": "ExtMethodResponse", + "description": "Successful result returned by an extension method outside the core ACP method set.", "allOf": [ { - "$ref": "#/$defs/MessageMcpResponse" + "$ref": "#/$defs/ExtResponse" } ] } @@ -8709,6 +9178,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8729,6 +9199,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8756,6 +9227,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8822,6 +9294,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8847,6 +9320,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8877,7 +9351,8 @@ { "type": "null" } - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -8885,6 +9360,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8906,14 +9382,16 @@ "null" ], "format": "uint32", - "minimum": 0 + "minimum": 0, + "x-deserialize-default-on-error": true }, "signal": { "description": "The signal that terminated the process (may be null if exited normally).", "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -8921,6 +9399,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } } @@ -8935,6 +9414,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8952,14 +9432,16 @@ "null" ], "format": "uint32", - "minimum": 0 + "minimum": 0, + "x-deserialize-default-on-error": true }, "signal": { "description": "The signal that terminated the process (may be null if exited normally).", "type": [ "string", "null" - ] + ], + "x-deserialize-default-on-error": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -8967,6 +9449,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8983,6 +9466,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -8999,10 +9483,11 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, - "oneOf": [ + "anyOf": [ { "description": "The user accepted and provided content.", "type": "object", @@ -9046,6 +9531,61 @@ "required": [ "action" ] + }, + { + "title": "other", + "description": "Custom or future elicitation action.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nAgents that do not understand this action should preserve the raw\npayload when storing, replaying, proxying, or forwarding elicitation\nresponses. They MUST NOT treat it as a known elicitation action.", + "type": "object", + "properties": { + "action": { + "description": "Custom or future elicitation action.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": [ + "action" + ], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "accept" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "decline" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "cancel" + } + }, + "required": [ + "action" + ] + } + ] + }, + "additionalProperties": true } ], "discriminator": { @@ -9123,6 +9663,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -9142,6 +9683,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -9283,6 +9825,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -9327,6 +9870,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -9366,7 +9910,9 @@ "type": "array", "items": { "$ref": "#/$defs/TextDocumentContentChangeEvent" - } + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -9374,6 +9920,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -9411,6 +9958,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -9440,6 +9988,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -9472,6 +10021,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -9525,6 +10075,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -9552,7 +10103,11 @@ }, "id": { "description": "The ID of the accepted suggestion.", - "type": "string" + "allOf": [ + { + "$ref": "#/$defs/NesSuggestionId" + } + ] }, "_meta": { "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", @@ -9560,6 +10115,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -9584,7 +10140,11 @@ }, "id": { "description": "The ID of the rejected suggestion.", - "type": "string" + "allOf": [ + { + "$ref": "#/$defs/NesSuggestionId" + } + ] }, "reason": { "description": "The reason for rejection.", @@ -9604,6 +10164,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, @@ -9640,7 +10201,7 @@ ] }, "CancelRequestNotification": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNotification to cancel an ongoing request.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/cancellation)", + "description": "Notification to cancel an ongoing request.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/cancellation)", "type": "object", "properties": { "requestId": { @@ -9657,6 +10218,7 @@ "object", "null" ], + "x-deserialize-default-on-error": true, "additionalProperties": true } }, diff --git a/scripts/gen_all.py b/scripts/gen_all.py index de1cb52..53d4fe9 100644 --- a/scripts/gen_all.py +++ b/scripts/gen_all.py @@ -140,8 +140,8 @@ def download_schema(repo: str, ref: str) -> None: print(exc, file=sys.stderr) sys.exit(1) - SCHEMA_JSON.write_text(json.dumps(schema_data, indent=2), encoding="utf-8") - META_JSON.write_text(json.dumps(meta_data, indent=2), encoding="utf-8") + SCHEMA_JSON.write_text(json.dumps(schema_data, indent=2) + "\n", encoding="utf-8") + META_JSON.write_text(json.dumps(meta_data, indent=2) + "\n", encoding="utf-8") VERSION_FILE.write_text(ref + "\n", encoding="utf-8") print(f"Fetched schema and meta from {repo}@{ref}") diff --git a/scripts/gen_schema.py b/scripts/gen_schema.py index 571ad34..936f6a4 100644 --- a/scripts/gen_schema.py +++ b/scripts/gen_schema.py @@ -85,9 +85,11 @@ "CreateElicitationRequest2": "CreateFormRequestElicitationRequest", "CreateElicitationRequest3": "CreateUrlSessionElicitationRequest", "CreateElicitationRequest4": "CreateUrlRequestElicitationRequest", + "CreateElicitationRequest5": "CreateOtherElicitationRequest", "CreateElicitationResponse1": "AcceptElicitationResponse", "CreateElicitationResponse2": "DeclineElicitationResponse", "CreateElicitationResponse3": "CancelElicitationResponse", + "CreateElicitationResponse4": "OtherElicitationResponse", "ElicitationFormMode1": "ElicitationFormSessionMode", "ElicitationFormMode2": "ElicitationFormRequestMode", "ElicitationPropertySchema1": "ElicitationStringPropertySchema", @@ -95,6 +97,9 @@ "ElicitationPropertySchema3": "ElicitationIntegerPropertySchema", "ElicitationPropertySchema4": "ElicitationBooleanPropertySchema", "ElicitationPropertySchema5": "ElicitationMultiSelectPropertySchema", + "ElicitationPropertySchema6": "ElicitationOtherPropertySchema", + "MultiSelectItems1": "StringMultiSelectItems", + "MultiSelectItems2": "OtherMultiSelectItems", "ElicitationUrlMode1": "ElicitationUrlSessionMode", "ElicitationUrlMode2": "ElicitationUrlRequestMode", "NesSuggestion1": "NesEditSuggestionVariant", @@ -103,6 +108,21 @@ "NesSuggestion4": "NesSearchAndReplaceSuggestionVariant", } +# Extensible ("custom or future") unions: known const-tagged variants plus a +# catch-all member tagged `"title": "other"`. _normalize_catchall_unions strips the +# discriminator and the catch-all's `not` clause so datamodel-codegen produces a plain +# union; the exclusion is restored at runtime by a field_validator injected into the +# catch-all class, so a malformed known variant fails instead of silently parsing as +# custom (mirrors the TypeScript SDK's excludeKnownTags). Maps union def name -> +# catch-all class name; the set is asserted against the schema in +# _validate_schema_alignment. +EXTENSIBLE_UNIONS: dict[str, str] = { + "CreateElicitationRequest": "CreateOtherElicitationRequest", + "CreateElicitationResponse": "OtherElicitationResponse", + "ElicitationPropertySchema": "ElicitationOtherPropertySchema", + "MultiSelectItems": "OtherMultiSelectItems", +} + ENUM_LITERAL_MAP: dict[str, tuple[str, ...]] = { "PermissionOptionKind": ( "allow_once", @@ -257,10 +277,45 @@ def _load_schema() -> dict[str, Any]: def _preprocess_schema_for_codegen(schema: dict[str, Any]) -> dict[str, Any]: + schema = _normalize_catchall_unions(schema) defs = schema.get("$defs", {}) return _distribute_composed_object_schemas(schema, defs) +def _normalize_catchall_unions(node: Any) -> Any: + # ACP "custom or future" unions include a member tagged `"title": "other"` whose + # discriminator (type/mode/action) is a free-form string. datamodel-codegen cannot + # put that in a discriminated union, so it emits `#-special-#` placeholder literals. + # Drop the discriminator (the union is then validated structurally) and collapse the + # catch-all to a permissive object so unknown variants round-trip their raw payload. + if isinstance(node, list): + return [_normalize_catchall_unions(item) for item in node] + if not isinstance(node, dict): + return node + + transformed = {key: _normalize_catchall_unions(value) for key, value in node.items()} + for combinator in COMBINATOR_KEYS: + members = transformed.get(combinator) + if not isinstance(members, list): + continue + if not any(isinstance(member, dict) and member.get("title") == "other" for member in members): + continue + transformed.pop("discriminator", None) + transformed[combinator] = [ + _collapse_catchall_member(member) if isinstance(member, dict) and member.get("title") == "other" else member + for member in members + ] + return transformed + + +def _collapse_catchall_member(member: dict[str, Any]) -> dict[str, Any]: + collapsed: dict[str, Any] = {"type": "object", "additionalProperties": True} + for key in ("title", "description", "properties", "required"): + if key in member: + collapsed[key] = member[key] + return collapsed + + def _distribute_composed_object_schemas(node: Any, defs: dict[str, Any]) -> Any: if isinstance(node, list): return [_distribute_composed_object_schemas(item, defs) for item in node] @@ -429,6 +484,7 @@ def postprocess_generated_schema(output_path: Path) -> list[str]: _ProcessingStep("attach description comments", _add_description_comments), _ProcessingStep("ensure custom BaseModel", _ensure_custom_base_model), _ProcessingStep("inject field validators", _inject_field_validators), + _ProcessingStep("inject deserialize defaults", _inject_deserialize_defaults), _ProcessingStep("inject schema aliases", _inject_schema_aliases), ) @@ -522,9 +578,29 @@ def _validate_schema_alignment() -> list[str]: warnings.append( f"Enum mismatch for '{enum_name}': schema.json -> {schema_values}, generated aliases -> {expected_values}" ) + + detected_unions = _detect_extensible_unions() + if detected_unions != set(EXTENSIBLE_UNIONS): + warnings.append( + f"Extensible union drift: schema defines {sorted(detected_unions)}, " + f"EXTENSIBLE_UNIONS lists {sorted(EXTENSIBLE_UNIONS)}. Update EXTENSIBLE_UNIONS, the " + "RENAME_MAP catch-all names, and the alias template together." + ) return warnings +def _detect_extensible_unions() -> set[str]: + defs = _load_schema().get("$defs", {}) + detected: set[str] = set() + for name, definition in defs.items(): + if not isinstance(definition, dict) or "discriminator" not in definition: + continue + members = definition.get("anyOf") or definition.get("oneOf") or [] + if any(isinstance(member, dict) and member.get("title") == "other" for member in members): + detected.add(name) + return detected + + def _load_schema_enum_literals() -> dict[str, tuple[str, ...]]: schema_data = json.loads(SCHEMA_JSON.read_text(encoding="utf-8")) defs = schema_data.get("$defs", {}) @@ -600,9 +676,58 @@ def _ensure_pydantic_import(content: str, name: str) -> str: return content +def _extensible_union_excluded_tags(union_def: dict[str, Any], discriminator: str) -> tuple[str, ...]: + members = union_def.get("anyOf") or union_def.get("oneOf") or [] + other = next((member for member in members if isinstance(member, dict) and member.get("title") == "other"), None) + if other is None: + return () + tags: list[str] = [] + for excluded in other.get("not", {}).get("anyOf", []): + const = excluded.get("properties", {}).get(discriminator, {}).get("const") + if isinstance(const, str) and const not in tags: + tags.append(const) + return tuple(tags) + + +def _catchall_exclusion_injections() -> list[FieldValidatorInjection]: + defs = _load_schema().get("$defs", {}) + injections: list[FieldValidatorInjection] = [] + for union_name, catchall_class in EXTENSIBLE_UNIONS.items(): + union_def = defs.get(union_name) + if not isinstance(union_def, dict): + continue + discriminator = union_def.get("discriminator", {}).get("propertyName") + if not discriminator: + continue + tags = _extensible_union_excluded_tags(union_def, discriminator) + if not tags: + continue + field = _schema_field_name(discriminator) + injections.append( + FieldValidatorInjection( + class_name=catchall_class, + field_name=field, + method_name=f"_reject_known_{field}", + argument_name="value", + return_type="Any", + comment_lines=( + "Restore the schema's `not` clause dropped for codegen: reject the known", + "variants' discriminator values so a malformed known variant fails instead", + "of silently parsing as this catch-all.", + ), + body_lines=( + f"if value in {tags!r}:", + f' raise ValueError("{field} value is reserved by a known variant")', + "return value", + ), + ) + ) + return injections + + def _inject_field_validators(content: str) -> str: - """Inject field_validator methods into classes listed in CLASS_VALIDATOR_INJECTIONS.""" - for injection in CLASS_VALIDATOR_INJECTIONS: + """Inject field_validator methods for CLASS_VALIDATOR_INJECTIONS and catch-all exclusions.""" + for injection in (*CLASS_VALIDATOR_INJECTIONS, *_catchall_exclusion_injections()): content = _ensure_pydantic_import(content, "field_validator") class_pattern = re.compile( @@ -627,6 +752,118 @@ def _append_validator( return content +def _inject_deserialize_defaults(content: str) -> str: + defs = _load_schema().get("$defs", {}) + + # `_meta` carries x-deserialize-default-on-error on almost every model; handle it once + # on the shared BaseModel with check_fields=False so every subclass inherits the salvage. + meta_validator = ( + '@field_validator("field_meta", mode="wrap", check_fields=False)\n' + "@classmethod\n" + "def _salvage_meta_on_error(cls, value: Any, handler: Any) -> Any:\n" + " return salvage_on_error(value, handler, lambda: None)\n" + ) + content, count = _append_class_method(content, r"class BaseModel\(_BaseModel\):", meta_validator) + if count == 0: + print("Warning: custom BaseModel not found for _meta salvage injection", file=sys.stderr) + + for class_name, definition in defs.items(): + if not isinstance(definition, dict): + continue + salvage_groups, skip_fields = _deserialize_field_specs(definition) + methods: list[str] = [] + for index, (fallback, fields) in enumerate(sorted(salvage_groups.items())): + arguments = ", ".join(f'"{field}"' for field in sorted(fields)) + methods.append( + f'@field_validator({arguments}, mode="wrap")\n' + "@classmethod\n" + f"def _salvage_on_error_{index}(cls, value: Any, handler: Any) -> Any:\n" + f" return salvage_on_error(value, handler, {fallback})\n" + ) + for index, field in enumerate(sorted(skip_fields)): + methods.append( + f'@field_validator("{field}", mode="wrap")\n' + "@classmethod\n" + f"def _skip_invalid_items_{index}(cls, value: Any, handler: Any) -> Any:\n" + " return skip_invalid_items(value, handler)\n" + ) + # A plain object $def renders as `class Name(BaseModel)` (or `_Name` after a + # collision rename). A union $def has no class of its own; its common properties + # distribute to the member variant classes, so target those instead. + targets = [rf"class _?{re.escape(class_name)}\(BaseModel\):"] + members = _union_member_classes(class_name) + if members: + targets = [rf"class {re.escape(member)}\(\w+\):" for member in members] + for method in methods: + for target in targets: + content, count = _append_class_method(content, target, method) + if count == 0: + print(f"Warning: no class matched {target!r} for deserialize injection", file=sys.stderr) + + content = _ensure_pydantic_import(content, "field_validator") + return _ensure_deserialize_import(content) + + +def _union_member_classes(union_name: str) -> list[str]: + return [new for old, new in RENAME_MAP.items() if re.fullmatch(rf"{re.escape(union_name)}\d+", old)] + + +def _deserialize_field_specs(definition: dict[str, Any]) -> tuple[dict[str, list[str]], list[str]]: + """Return ({fallback_expr: [field, ...]}, [skip_field, ...]) for a $def. `_meta` is handled + on the shared BaseModel and excluded here.""" + required = set(definition.get("required", [])) + salvage: dict[str, list[str]] = {} + skip: list[str] = [] + for prop_name, prop in definition.get("properties", {}).items(): + if not isinstance(prop, dict) or prop_name == "_meta": + continue + field = _schema_field_name(prop_name) + if prop.get("x-deserialize-skip-invalid-items"): + skip.append(field) + elif prop.get("x-deserialize-default-on-error"): + salvage.setdefault(_fallback_expression(prop, prop_name in required), []).append(field) + return salvage, skip + + +def _fallback_expression(prop: dict[str, Any], is_required: bool) -> str: + if "default" in prop: + return f"lambda: {prop['default']!r}" + if _is_array_schema(prop) and (is_required or not _schema_allows_null(prop, {})): + return "lambda: []" + return "lambda: None" + + +def _is_array_schema(prop: dict[str, Any]) -> bool: + prop_type = prop.get("type") + if prop_type == "array" or (isinstance(prop_type, list) and "array" in prop_type): + return True + return "items" in prop + + +def _append_class_method(content: str, header_pattern: str, method_text: str) -> tuple[str, int]: + pattern = re.compile(rf"({header_pattern})(.*?)(?=\nclass |\Z)", re.DOTALL) + + def _append(match: re.Match[str]) -> str: + indented = "\n" + textwrap.indent(method_text, " ") + return match.group(1) + match.group(2) + indented + "\n" + + return pattern.subn(_append, content, count=1) + + +def _ensure_deserialize_import(content: str) -> str: + # Absolute import (not relative): gen_signature.py loads schema.py as a standalone + # module with no package context, where `from ._deserialize` cannot resolve. + statement = "from acp._deserialize import salvage_on_error, skip_invalid_items" + if statement in content: + return content + lines = content.splitlines() + for idx, line in enumerate(lines): + if line.startswith("from pydantic import "): + lines.insert(idx + 1, statement) + return "\n".join(lines) + "\n" + return content + + def _inject_schema_aliases(content: str) -> str: if "CreateElicitationRequest = Union[" in content: return content @@ -649,11 +886,13 @@ def _inject_schema_aliases(content: str) -> str: CreateElicitationRequest = Union[ CreateFormElicitationRequest, CreateUrlElicitationRequest, + CreateOtherElicitationRequest, ] CreateElicitationResponse = Union[ AcceptElicitationResponse, DeclineElicitationResponse, CancelElicitationResponse, + OtherElicitationResponse, ] """) pattern = re.compile( diff --git a/src/acp/__init__.py b/src/acp/__init__.py index d343529..e6cd484 100644 --- a/src/acp/__init__.py +++ b/src/acp/__init__.py @@ -49,6 +49,7 @@ CreateFormElicitationRequest, CreateFormRequestElicitationRequest, CreateFormSessionElicitationRequest, + CreateOtherElicitationRequest, CreateTerminalRequest, CreateTerminalResponse, CreateUrlElicitationRequest, @@ -64,6 +65,7 @@ ElicitationMode, ElicitationMultiSelectPropertySchema, ElicitationNumberPropertySchema, + ElicitationOtherPropertySchema, ElicitationSchema, ElicitationStringPropertySchema, ElicitationUrlCapabilities, @@ -77,6 +79,7 @@ LoadSessionResponse, NewSessionRequest, NewSessionResponse, + OtherElicitationResponse, PromptRequest, PromptResponse, ReadTextFileRequest, @@ -156,6 +159,7 @@ "ElicitationIntegerPropertySchema", "ElicitationBooleanPropertySchema", "ElicitationMultiSelectPropertySchema", + "ElicitationOtherPropertySchema", "CreateElicitationRequest", "CreateElicitationResponse", "CreateFormElicitationRequest", @@ -164,9 +168,11 @@ "CreateUrlElicitationRequest", "CreateUrlSessionElicitationRequest", "CreateUrlRequestElicitationRequest", + "CreateOtherElicitationRequest", "AcceptElicitationResponse", "DeclineElicitationResponse", "CancelElicitationResponse", + "OtherElicitationResponse", "CompleteElicitationNotification", # terminal types "CreateTerminalRequest", diff --git a/src/acp/_deserialize.py b/src/acp/_deserialize.py new file mode 100644 index 0000000..1cc2ea9 --- /dev/null +++ b/src/acp/_deserialize.py @@ -0,0 +1,43 @@ +"""Runtime helpers that restore the lenient-deserialization semantics the ACP schema +declares via ``x-deserialize-default-on-error`` and ``x-deserialize-skip-invalid-items`` +but that generated Pydantic models cannot express directly. + +Referenced by ``field_validator`` methods that ``scripts/gen_schema.py`` injects into +``schema.py``. Mirrors the TypeScript SDK's ``src/schema-deserialize.ts``. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from pydantic import ValidationError + + +def salvage_on_error(value: Any, handler: Callable[[Any], Any], fallback: Callable[[], Any]) -> Any: + """Return ``fallback()`` when ``value`` fails validation, otherwise the validated value. + + Restores ``x-deserialize-default-on-error``: a malformed non-critical field is replaced + with its default rather than failing the whole payload. + """ + try: + return handler(value) + except ValidationError: + return fallback() + + +def skip_invalid_items(value: Any, handler: Callable[[Any], Any]) -> Any: + """Drop array items that fail validation instead of failing the whole array. + + Restores ``x-deserialize-skip-invalid-items``. Each item is validated through the field's + own list handler, so item coercion and salvaging still apply to the survivors. + """ + if not isinstance(value, list): + return handler(value) + salvaged: list[Any] = [] + for item in value: + try: + salvaged.append(handler([item])[0]) + except ValidationError: + continue + return salvaged diff --git a/src/acp/client/router.py b/src/acp/client/router.py index c8b26bb..204a67b 100644 --- a/src/acp/client/router.py +++ b/src/acp/client/router.py @@ -11,11 +11,9 @@ from ..schema import ( CompleteElicitationNotification, CreateElicitationRequest, - CreateFormElicitationRequest, CreateFormRequestElicitationRequest, CreateFormSessionElicitationRequest, CreateTerminalRequest, - CreateUrlElicitationRequest, CreateUrlRequestElicitationRequest, CreateUrlSessionElicitationRequest, ElicitationFormRequestMode, @@ -37,12 +35,12 @@ _CREATE_ELICITATION_REQUEST_ADAPTER = TypeAdapter(CreateElicitationRequest) -def _validate_create_elicitation_request(params: Any) -> CreateFormElicitationRequest | CreateUrlElicitationRequest: +def _validate_create_elicitation_request(params: Any) -> CreateElicitationRequest: return _CREATE_ELICITATION_REQUEST_ADAPTER.validate_python(params) def _mode_from_create_elicitation_request( - request: CreateFormElicitationRequest | CreateUrlElicitationRequest, + request: CreateElicitationRequest, ) -> ElicitationFormSessionMode | ElicitationFormRequestMode | ElicitationUrlSessionMode | ElicitationUrlRequestMode: if isinstance(request, CreateFormSessionElicitationRequest): return ElicitationFormSessionMode( @@ -69,7 +67,7 @@ def _mode_from_create_elicitation_request( elicitation_id=request.elicitation_id, url=request.url, ) - raise TypeError(f"Unsupported elicitation request: {type(request).__name__}") + raise RequestError.invalid_params({"details": f"Unsupported elicitation mode: {request.mode!r}"}) def _make_create_elicitation_handler(client: Client) -> Any: diff --git a/src/acp/meta.py b/src/acp/meta.py index a7bd914..9c883c5 100644 --- a/src/acp/meta.py +++ b/src/acp/meta.py @@ -1,5 +1,5 @@ # Generated from schema/meta.json. Do not edit by hand. -# Schema ref: refs/tags/schema-v1.16.0 +# Schema ref: refs/tags/schema-v1.19.0 AGENT_METHODS = { "initialize": "initialize", "authenticate": "authenticate", diff --git a/src/acp/schema.py b/src/acp/schema.py index 6b74f46..e2dd1f1 100644 --- a/src/acp/schema.py +++ b/src/acp/schema.py @@ -1,12 +1,13 @@ # Generated from schema/schema.json. Do not edit by hand. -# Schema ref: refs/tags/schema-v1.16.0 +# Schema ref: refs/tags/schema-v1.19.0 from __future__ import annotations from enum import Enum from typing import Annotated, Any, Dict, List, Literal, Optional, Union -from pydantic import AnyUrl, BaseModel as _BaseModel, Field, RootModel, ConfigDict, field_validator +from pydantic import AnyUrl, BaseModel as _BaseModel, ConfigDict, Field, RootModel, field_validator +from acp._deserialize import salvage_on_error, skip_invalid_items PermissionOptionKind = Literal["allow_once", "allow_always", "reject_once", "reject_always"] PlanEntryPriority = Literal["high", "medium", "low"] @@ -25,6 +26,11 @@ def __getattr__(self, item: str) -> Any: return getattr(self, snake_cased) raise AttributeError(f"'{type(self).__name__}' object has no attribute '{item}'") + @field_validator("field_meta", mode="wrap", check_fields=False) + @classmethod + def _salvage_meta_on_error(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class Jsonrpc(Enum): field_2_0 = "2.0" @@ -55,6 +61,11 @@ class ReadTextFileRequest(BaseModel): ), ] = None + @field_validator("limit", "line", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class TextResourceContents(BaseModel): # MIME type describing the encoded media payload. @@ -82,6 +93,11 @@ class TextResourceContents(BaseModel): ), ] = None + @field_validator("mime_type", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class BlobResourceContents(BaseModel): # Base64-encoded bytes for a binary resource payload. @@ -109,10 +125,15 @@ class BlobResourceContents(BaseModel): ), ] = None + @field_validator("mime_type", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class Diff(BaseModel): - # The file path being modified. - path: Annotated[str, Field(description="The file path being modified.")] + # The absolute file path being modified. + path: Annotated[str, Field(description="The absolute file path being modified.")] # The original content (None for new files). old_text: Annotated[ Optional[str], @@ -133,6 +154,11 @@ class Diff(BaseModel): ), ] = None + @field_validator("old_text", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class Terminal(BaseModel): # Identifier of the terminal instance to embed in the content stream. @@ -158,8 +184,8 @@ class Terminal(BaseModel): class ToolCallLocation(BaseModel): - # The file path being accessed or modified. - path: Annotated[str, Field(description="The file path being accessed or modified.")] + # The absolute file path being accessed or modified. + path: Annotated[str, Field(description="The absolute file path being accessed or modified.")] # Optional line number within the file. line: Annotated[Optional[int], Field(description="Optional line number within the file.", ge=0)] = None # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -175,6 +201,11 @@ class ToolCallLocation(BaseModel): ), ] = None + @field_validator("line", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class EnvVariable(BaseModel): # The name of the environment variable. @@ -277,6 +308,50 @@ class KillTerminalRequest(BaseModel): ] = None +class CreateOtherElicitationRequest(BaseModel): + model_config = ConfigDict( + extra="allow", + ) + # A human-readable message describing what input is needed. + message: Annotated[ + str, + Field(description="A human-readable message describing what input is needed."), + ] + # The _meta property is reserved by ACP to allow clients and agents to attach additional + # metadata to their interactions. Implementations MUST NOT make assumptions about values at + # these keys. + # + # See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + field_meta: Annotated[ + Optional[Dict[str, Any]], + Field( + alias="_meta", + description="The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + ), + ] = None + # Custom or future elicitation mode. + # + # Values beginning with `_` are reserved for implementation-specific + # extensions. Unknown values that do not begin with `_` are reserved for + # future ACP variants. + mode: Annotated[ + str, + Field( + description="Custom or future elicitation mode.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants." + ), + ] + + @field_validator("mode", mode="before") + @classmethod + def _reject_known_mode(cls, value: Any) -> Any: + # Restore the schema's `not` clause dropped for codegen: reject the known + # variants' discriminator values so a malformed known variant fails instead + # of silently parsing as this catch-all. + if value in ("form", "url"): + raise ValueError("mode value is reserved by a known variant") + return value + + class ElicitationSessionScope(BaseModel): # The session this elicitation is tied to. session_id: Annotated[ @@ -289,6 +364,11 @@ class ElicitationSessionScope(BaseModel): Field(alias="toolCallId", description="Optional tool call within the session."), ] = None + @field_validator("tool_call_id", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class ElicitationRequestScope(BaseModel): # The request this elicitation is tied to. @@ -298,11 +378,40 @@ class ElicitationRequestScope(BaseModel): ] +class ElicitationOtherPropertySchema(BaseModel): + model_config = ConfigDict( + extra="allow", + ) + # Custom or future elicitation property schema type. + # + # Values beginning with `_` are reserved for implementation-specific + # extensions. Unknown values that do not begin with `_` are reserved for + # future ACP variants. + type: Annotated[ + str, + Field( + description="Custom or future elicitation property schema type.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants." + ), + ] + + @field_validator("type", mode="before") + @classmethod + def _reject_known_type(cls, value: Any) -> Any: + # Restore the schema's `not` clause dropped for codegen: reject the known + # variants' discriminator values so a malformed known variant fails instead + # of silently parsing as this catch-all. + if value in ("string", "number", "integer", "boolean", "array"): + raise ValueError("type value is reserved by a known variant") + return value + + class EnumOption(BaseModel): # The constant value for this option. const: Annotated[str, Field(description="The constant value for this option.")] # Human-readable title for this option. title: Annotated[str, Field(description="Human-readable title for this option.")] + # Human-readable description. + description: Annotated[Optional[str], Field(description="Human-readable description.")] = None # The _meta property is reserved by ACP to allow clients and agents to attach additional # metadata to their interactions. Implementations MUST NOT make assumptions about values at # these keys. @@ -316,6 +425,11 @@ class EnumOption(BaseModel): ), ] = None + @field_validator("description", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class StringPropertySchema(BaseModel): # Optional title for the property. @@ -364,6 +478,11 @@ class StringPropertySchema(BaseModel): ), ] = None + @field_validator("default", "description", "title", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class NumberPropertySchema(BaseModel): # Optional title for the property. @@ -389,6 +508,11 @@ class NumberPropertySchema(BaseModel): ), ] = None + @field_validator("default", "description", "title", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class IntegerPropertySchema(BaseModel): # Optional title for the property. @@ -414,6 +538,11 @@ class IntegerPropertySchema(BaseModel): ), ] = None + @field_validator("default", "description", "title", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class BooleanPropertySchema(BaseModel): # Optional title for the property. @@ -435,6 +564,55 @@ class BooleanPropertySchema(BaseModel): ), ] = None + @field_validator("default", "description", "title", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + +class OtherMultiSelectItems(BaseModel): + model_config = ConfigDict( + extra="allow", + ) + # Custom or future multi-select item type. + # + # Values beginning with `_` are reserved for implementation-specific + # extensions. Unknown values that do not begin with `_` are reserved for + # future ACP variants. + type: Annotated[ + str, + Field( + description="Custom or future multi-select item type.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants." + ), + ] + + @field_validator("type", mode="before") + @classmethod + def _reject_known_type(cls, value: Any) -> Any: + # Restore the schema's `not` clause dropped for codegen: reject the known + # variants' discriminator values so a malformed known variant fails instead + # of silently parsing as this catch-all. + if value in ("string",): + raise ValueError("type value is reserved by a known variant") + return value + + +class _StringMultiSelectItems(BaseModel): + # Allowed enum values. + enum: Annotated[List[str], Field(description="Allowed enum values.")] + # The _meta property is reserved by ACP to allow clients and agents to attach additional + # metadata to their interactions. Implementations MUST NOT make assumptions about values at + # these keys. + # + # See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + field_meta: Annotated[ + Optional[Dict[str, Any]], + Field( + alias="_meta", + description="The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + ), + ] = None + class TitledMultiSelectItems(BaseModel): # Titled enum options. @@ -542,6 +720,11 @@ class PromptCapabilities(BaseModel): ), ] = None + @field_validator("audio", "embedded_context", "image", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: False) + class McpCapabilities(BaseModel): # Agent supports [`McpServer::Http`]. @@ -572,6 +755,11 @@ class McpCapabilities(BaseModel): ), ] = None + @field_validator("acp", "http", "sse", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: False) + class SessionListCapabilities(BaseModel): # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -776,6 +964,11 @@ class NesRecentFilesCapabilities(BaseModel): ), ] = None + @field_validator("max_count", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class NesRelatedSnippetsCapabilities(BaseModel): # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -815,6 +1008,11 @@ class NesEditHistoryCapabilities(BaseModel): ), ] = None + @field_validator("max_count", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class NesUserActionsCapabilities(BaseModel): # Maximum number of user actions the agent can use. @@ -839,6 +1037,11 @@ class NesUserActionsCapabilities(BaseModel): ), ] = None + @field_validator("max_count", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class NesOpenFilesCapabilities(BaseModel): # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -911,6 +1114,21 @@ class AuthEnvVar(BaseModel): ), ] = None + @field_validator("optional", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: False) + + @field_validator("label", mode="wrap") + @classmethod + def _salvage_on_error_1(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("secret", mode="wrap") + @classmethod + def _salvage_on_error_2(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: True) + class AuthMethodEnvVar(BaseModel): # Unique identifier for this authentication method. @@ -945,6 +1163,16 @@ class AuthMethodEnvVar(BaseModel): ), ] = None + @field_validator("description", "link", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("vars", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class AuthMethodTerminal(BaseModel): # Unique identifier for this authentication method. @@ -979,6 +1207,16 @@ class AuthMethodTerminal(BaseModel): ), ] = None + @field_validator("description", "env", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("args", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class AuthMethodAgent(BaseModel): # Unique identifier for this authentication method. @@ -1003,6 +1241,11 @@ class AuthMethodAgent(BaseModel): ), ] = None + @field_validator("description", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class Implementation(BaseModel): # Intended for programmatic or logical use, but can be used as a display @@ -1044,6 +1287,11 @@ class Implementation(BaseModel): ), ] = None + @field_validator("title", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class AuthenticateResponse(BaseModel): # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -1063,7 +1311,7 @@ class AuthenticateResponse(BaseModel): class ProviderCurrentConfig(BaseModel): # Protocol currently used by this provider. api_type: Annotated[ - str, + Union[str, Dict[str, Any]], Field(alias="apiType", description="Protocol currently used by this provider."), ] # Base URL currently used by this provider. @@ -1156,6 +1404,11 @@ class SessionMode(BaseModel): ), ] = None + @field_validator("description", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class SessionConfigSelectOption(BaseModel): # Unique identifier for this option value. @@ -1177,6 +1430,11 @@ class SessionConfigSelectOption(BaseModel): ), ] = None + @field_validator("description", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class SessionConfigBoolean(BaseModel): # The current value of the boolean option. @@ -1226,6 +1484,16 @@ class SessionInfo(BaseModel): ), ] = None + @field_validator("title", "updated_at", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("additional_directories", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class DeleteSessionResponse(BaseModel): # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -1328,6 +1596,11 @@ class Usage(BaseModel): ), ] = None + @field_validator("cached_read_tokens", "cached_write_tokens", "thought_tokens", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class StartNesResponse(BaseModel): # The session ID for the newly started NES session. @@ -1466,7 +1739,7 @@ class CloseNesResponse(BaseModel): class PlanFile(BaseModel): # The plan ID to update. - id: Annotated[str, Field(description="The plan ID to update.")] + plan_id: Annotated[str, Field(alias="planId", description="The plan ID to update.")] # The URI of the file containing the plan. uri: Annotated[str, Field(description="The URI of the file containing the plan.")] # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -1485,7 +1758,7 @@ class PlanFile(BaseModel): class PlanMarkdown(BaseModel): # The plan ID to update. - id: Annotated[str, Field(description="The plan ID to update.")] + plan_id: Annotated[str, Field(alias="planId", description="The plan ID to update.")] # Markdown content for the plan. content: Annotated[str, Field(description="Markdown content for the plan.")] # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -1504,7 +1777,7 @@ class PlanMarkdown(BaseModel): class PlanRemoved(BaseModel): # The plan ID to remove. - id: Annotated[str, Field(description="The plan ID to remove.")] + plan_id: Annotated[str, Field(alias="planId", description="The plan ID to remove.")] # The _meta property is reserved by ACP to allow clients and agents to attach additional # metadata to their interactions. Implementations MUST NOT make assumptions about values at # these keys. @@ -1583,6 +1856,11 @@ class _SessionInfoUpdate(BaseModel): ), ] = None + @field_validator("title", "updated_at", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class Cost(BaseModel): # Total cumulative cost for session. @@ -1623,6 +1901,11 @@ class _UsageUpdate(BaseModel): ), ] = None + @field_validator("cost", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class CompleteElicitationNotification(BaseModel): # The ID of the elicitation that completed. @@ -1680,6 +1963,11 @@ class MessageMcpNotification(BaseModel): ), ] = None + @field_validator("params", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class FileSystemCapabilities(BaseModel): # Whether the Client supports `fs/read_text_file` requests. @@ -1711,6 +1999,11 @@ class FileSystemCapabilities(BaseModel): ), ] = None + @field_validator("read_text_file", "write_text_file", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: False) + class BooleanConfigOptionCapabilities(BaseModel): # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -1765,6 +2058,11 @@ class AuthCapabilities(BaseModel): ), ] = None + @field_validator("terminal", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: False) + class ElicitationFormCapabilities(BaseModel): # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -1881,10 +2179,13 @@ class ListProvidersRequest(BaseModel): class SetProviderRequest(BaseModel): - # Provider id to configure. - id: Annotated[str, Field(description="Provider id to configure.")] + # Provider ID to configure. + provider_id: Annotated[str, Field(alias="providerId", description="Provider ID to configure.")] # Protocol type for this provider. - api_type: Annotated[str, Field(alias="apiType", description="Protocol type for this provider.")] + api_type: Annotated[ + Union[str, Dict[str, Any]], + Field(alias="apiType", description="Protocol type for this provider."), + ] # Base URL for requests sent through this provider. base_url: Annotated[ str, @@ -1916,8 +2217,8 @@ class SetProviderRequest(BaseModel): class DisableProviderRequest(BaseModel): - # Provider id to disable. - id: Annotated[str, Field(description="Provider id to disable.")] + # Provider ID to disable. + provider_id: Annotated[str, Field(alias="providerId", description="Provider ID to disable.")] # The _meta property is reserved by ACP to allow clients and agents to attach additional # metadata to their interactions. Implementations MUST NOT make assumptions about values at # these keys. @@ -2021,10 +2322,11 @@ class McpServerAcp(BaseModel): # # Providers MUST NOT reuse an ID for multiple ACP-transport MCP servers that are visible # on the same ACP connection. - id: Annotated[ + server_id: Annotated[ str, Field( - description="Unique identifier for this MCP server, generated by the component providing it.\n\nProviders MUST NOT reuse an ID for multiple ACP-transport MCP servers that are visible\non the same ACP connection." + alias="serverId", + description="Unique identifier for this MCP server, generated by the component providing it.\n\nProviders MUST NOT reuse an ID for multiple ACP-transport MCP servers that are visible\non the same ACP connection.", ), ] # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -2044,8 +2346,8 @@ class McpServerAcp(BaseModel): class McpServerStdio(BaseModel): # Human-readable name identifying this MCP server. name: Annotated[str, Field(description="Human-readable name identifying this MCP server.")] - # Path to the MCP server executable. - command: Annotated[str, Field(description="Path to the MCP server executable.")] + # Absolute path to the MCP server executable. + command: Annotated[str, Field(description="Absolute path to the MCP server executable.")] # Command-line arguments to pass to the MCP server. args: Annotated[ List[str], @@ -2486,6 +2788,11 @@ class TerminalExitStatus(BaseModel): ), ] = None + @field_validator("exit_code", "signal", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class ReleaseTerminalResponse(BaseModel): # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -2530,6 +2837,11 @@ class WaitForTerminalExitResponse(BaseModel): ), ] = None + @field_validator("exit_code", "signal", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class KillTerminalResponse(BaseModel): # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -2578,6 +2890,45 @@ class CancelElicitationResponse(BaseModel): action: Literal["cancel"] +class OtherElicitationResponse(BaseModel): + model_config = ConfigDict( + extra="allow", + ) + # The _meta property is reserved by ACP to allow clients and agents to attach additional + # metadata to their interactions. Implementations MUST NOT make assumptions about values at + # these keys. + # + # See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + field_meta: Annotated[ + Optional[Dict[str, Any]], + Field( + alias="_meta", + description="The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + ), + ] = None + # Custom or future elicitation action. + # + # Values beginning with `_` are reserved for implementation-specific + # extensions. Unknown values that do not begin with `_` are reserved for + # future ACP variants. + action: Annotated[ + str, + Field( + description="Custom or future elicitation action.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants." + ), + ] + + @field_validator("action", mode="before") + @classmethod + def _reject_known_action(cls, value: Any) -> Any: + # Restore the schema's `not` clause dropped for codegen: reject the known + # variants' discriminator values so a malformed known variant fails instead + # of silently parsing as this catch-all. + if value in ("accept", "decline", "cancel"): + raise ValueError("action value is reserved by a known variant") + return value + + class ElicitationContentValue(RootModel[Union[str, int, float, bool, List[str]]]): # Allowed wire representations for [`ElicitationContentValue`]. root: Annotated[ @@ -2836,6 +3187,16 @@ class Annotations(BaseModel): ), ] = None + @field_validator("last_modified", "priority", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("audience", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class TextContent(BaseModel): # Optional annotations that help clients decide how to display or route this content. @@ -2858,6 +3219,11 @@ class TextContent(BaseModel): ), ] = None + @field_validator("annotations", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class ImageContent(BaseModel): # Optional annotations that help clients decide how to display or route this content. @@ -2893,6 +3259,11 @@ class ImageContent(BaseModel): ), ] = None + @field_validator("annotations", "uri", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class AudioContent(BaseModel): # Optional annotations that help clients decide how to display or route this content. @@ -2923,6 +3294,11 @@ class AudioContent(BaseModel): ), ] = None + @field_validator("annotations", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class ResourceLink(BaseModel): # Optional annotations that help clients decide how to display or route this content. @@ -2967,6 +3343,11 @@ class ResourceLink(BaseModel): ), ] = None + @field_validator("annotations", "description", "mime_type", "size", "title", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class EmbeddedResource(BaseModel): # Optional annotations that help clients decide how to display or route this content. @@ -2992,6 +3373,11 @@ class EmbeddedResource(BaseModel): ), ] = None + @field_validator("annotations", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class PermissionOption(BaseModel): # Unique identifier for this permission option. @@ -3032,10 +3418,10 @@ class CreateTerminalRequest(BaseModel): Optional[List[EnvVariable]], Field(description="Environment variables for the command."), ] = None - # Working directory for the command (absolute path). + # Working directory for the command. Must be an absolute path. cwd: Annotated[ Optional[str], - Field(description="Working directory for the command (absolute path)."), + Field(description="Working directory for the command. Must be an absolute path."), ] = None # Maximum number of output bytes to retain. # @@ -3066,6 +3452,21 @@ class CreateTerminalRequest(BaseModel): ), ] = None + @field_validator("cwd", "output_byte_limit", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("args", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + + @field_validator("env", mode="wrap") + @classmethod + def _skip_invalid_items_1(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class CreateUrlSessionElicitationRequest(ElicitationSessionScope): # A human-readable message describing what input is needed. @@ -3145,11 +3546,32 @@ class ElicitationBooleanPropertySchema(BooleanPropertySchema): type: Literal["boolean"] -class UntitledMultiSelectItems(BaseModel): - # Item type discriminator. Must be `"string"`. - type: Annotated[str, Field(description='Item type discriminator. Must be `"string"`.')] - # Allowed enum values. - enum: Annotated[List[str], Field(description="Allowed enum values.")] +class StringMultiSelectItems(_StringMultiSelectItems): + type: Literal["string"] + + +class MultiSelectPropertySchema(BaseModel): + # Optional title for the property. + title: Annotated[Optional[str], Field(description="Optional title for the property.")] = None + # Human-readable description. + description: Annotated[Optional[str], Field(description="Human-readable description.")] = None + # Minimum number of items to select. + min_items: Annotated[ + Optional[int], + Field(alias="minItems", description="Minimum number of items to select.", ge=0), + ] = None + # Maximum number of items to select. + max_items: Annotated[ + Optional[int], + Field(alias="maxItems", description="Maximum number of items to select.", ge=0), + ] = None + # The items definition describing allowed values. + items: Annotated[ + Union[StringMultiSelectItems, OtherMultiSelectItems, TitledMultiSelectItems], + Field(description="The items definition describing allowed values."), + ] + # Default selected values. + default: Annotated[Optional[List[str]], Field(description="Default selected values.")] = None # The _meta property is reserved by ACP to allow clients and agents to attach additional # metadata to their interactions. Implementations MUST NOT make assumptions about values at # these keys. @@ -3163,13 +3585,23 @@ class UntitledMultiSelectItems(BaseModel): ), ] = None + @field_validator("description", "title", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("default", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class ConnectMcpRequest(BaseModel): # The ACP MCP server ID that was provided by the component declaring the MCP server. - acp_id: Annotated[ + server_id: Annotated[ str, Field( - alias="acpId", + alias="serverId", description="The ACP MCP server ID that was provided by the component declaring the MCP server.", ), ] @@ -3223,9 +3655,14 @@ class MessageMcpRequest(BaseModel): class SessionCapabilities(BaseModel): # Whether the agent supports `session/list`. + # + # Optional. Omitted or `null` both mean the agent does not advertise support. + # Supplying `{}` means the agent supports listing sessions. list: Annotated[ Optional[SessionListCapabilities], - Field(description="Whether the agent supports `session/list`."), + Field( + description="Whether the agent supports `session/list`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports listing sessions." + ), ] = None # Whether the agent supports `session/delete`. # @@ -3239,6 +3676,10 @@ class SessionCapabilities(BaseModel): ] = None # Whether the agent supports `additionalDirectories` on supported session lifecycle requests. # + # Optional. Omitted or `null` both mean the agent does not advertise support. + # Supplying `{}` means the agent supports `additionalDirectories` on + # supported session lifecycle requests. + # # Agents that also support `session/list` may return # `SessionInfo.additionalDirectories` to report the complete ordered # additional-root list associated with a listed session. @@ -3246,7 +3687,7 @@ class SessionCapabilities(BaseModel): Optional[SessionAdditionalDirectoriesCapabilities], Field( alias="additionalDirectories", - description="Whether the agent supports `additionalDirectories` on supported session lifecycle requests.\n\nAgents that also support `session/list` may return\n`SessionInfo.additionalDirectories` to report the complete ordered\nadditional-root list associated with a listed session.", + description="Whether the agent supports `additionalDirectories` on supported session lifecycle requests.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports `additionalDirectories` on\nsupported session lifecycle requests.\n\nAgents that also support `session/list` may return\n`SessionInfo.additionalDirectories` to report the complete ordered\nadditional-root list associated with a listed session.", ), ] = None # **UNSTABLE** @@ -3254,21 +3695,34 @@ class SessionCapabilities(BaseModel): # This capability is not part of the spec yet, and may be removed or changed at any point. # # Whether the agent supports `session/fork`. + # + # Optional. Omitted or `null` both mean the agent does not advertise support. + # Supplying `{}` means the agent supports forking sessions. fork: Annotated[ Optional[SessionForkCapabilities], Field( - description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/fork`." + description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the agent supports `session/fork`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports forking sessions." ), ] = None # Whether the agent supports `session/resume`. + # + # Optional. Omitted or `null` both mean the agent does not advertise support. + # Supplying `{}` means the agent supports resuming sessions. resume: Annotated[ Optional[SessionResumeCapabilities], - Field(description="Whether the agent supports `session/resume`."), + Field( + description="Whether the agent supports `session/resume`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports resuming sessions." + ), ] = None # Whether the agent supports `session/close`. + # + # Optional. Omitted or `null` both mean the agent does not advertise support. + # Supplying `{}` means the agent supports closing sessions. close: Annotated[ Optional[SessionCloseCapabilities], - Field(description="Whether the agent supports `session/close`."), + Field( + description="Whether the agent supports `session/close`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports closing sessions." + ), ] = None # The _meta property is reserved by ACP to allow clients and agents to attach additional # metadata to their interactions. Implementations MUST NOT make assumptions about values at @@ -3283,15 +3737,21 @@ class SessionCapabilities(BaseModel): ), ] = None + @field_validator("additional_directories", "close", "delete", "fork", "list", "resume", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class AgentAuthCapabilities(BaseModel): # Whether the agent supports the logout method. # - # By supplying `{}` it means that the agent supports the logout method. + # Optional. Omitted or `null` both mean the agent does not advertise support. + # Supplying `{}` means the agent supports the logout method. logout: Annotated[ Optional[LogoutCapabilities], Field( - description="Whether the agent supports the logout method.\n\nBy supplying `{}` it means that the agent supports the logout method." + description="Whether the agent supports the logout method.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports the logout method." ), ] = None # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -3307,6 +3767,11 @@ class AgentAuthCapabilities(BaseModel): ), ] = None + @field_validator("logout", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class NesDocumentDidChangeCapabilities(BaseModel): # The sync kind the agent wants: `"full"` or `"incremental"`. @@ -3387,6 +3852,13 @@ class NesContextCapabilities(BaseModel): ), ] = None + @field_validator( + "diagnostics", "edit_history", "open_files", "recent_files", "related_snippets", "user_actions", mode="wrap" + ) + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class EnvVarAuthMethod(AuthMethodEnvVar): type: Literal["env_var"] @@ -3398,15 +3870,24 @@ class TerminalAuthMethod(AuthMethodTerminal): class ProviderInfo(BaseModel): # Provider identifier, for example "main" or "openai". - id: Annotated[str, Field(description='Provider identifier, for example "main" or "openai".')] + provider_id: Annotated[ + str, + Field( + alias="providerId", + description='Provider identifier, for example "main" or "openai".', + ), + ] # Supported protocol types for this provider. - supported: Annotated[List[str], Field(description="Supported protocol types for this provider.")] + supported: Annotated[ + List[Union[str, Dict[str, Any]]], + Field(description="Supported protocol types for this provider."), + ] # Whether this provider is mandatory and cannot be disabled via `providers/disable`. - # If true, clients must not call `providers/disable` for this id. + # If true, clients must not call `providers/disable` for this provider ID. required: Annotated[ bool, Field( - description="Whether this provider is mandatory and cannot be disabled via `providers/disable`.\nIf true, clients must not call `providers/disable` for this id." + description="Whether this provider is mandatory and cannot be disabled via `providers/disable`.\nIf true, clients must not call `providers/disable` for this provider ID." ), ] # Current effective non-secret routing config. @@ -3428,6 +3909,11 @@ class ProviderInfo(BaseModel): ), ] = None + @field_validator("supported", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class SessionModeState(BaseModel): # The current mode the Agent is in. @@ -3456,6 +3942,11 @@ class SessionModeState(BaseModel): ), ] = None + @field_validator("available_modes", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class SessionConfigOptionBoolean(SessionConfigBoolean): # Unique identifier for the configuration option. @@ -3469,7 +3960,7 @@ class SessionConfigOptionBoolean(SessionConfigBoolean): ] = None # Optional semantic category for this option (UX only). category: Annotated[ - Optional[str], + Optional[Union[str, Dict[str, Any]]], Field(description="Optional semantic category for this option (UX only)."), ] = None # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -3486,6 +3977,11 @@ class SessionConfigOptionBoolean(SessionConfigBoolean): ] = None type: Literal["boolean"] + @field_validator("category", "description", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class SessionConfigSelectGroup(BaseModel): # Unique identifier for this group. @@ -3510,6 +4006,11 @@ class SessionConfigSelectGroup(BaseModel): ), ] = None + @field_validator("options", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class ListSessionsResponse(BaseModel): # Array of session information objects @@ -3536,6 +4037,16 @@ class ListSessionsResponse(BaseModel): ), ] = None + @field_validator("next_cursor", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("sessions", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class PromptResponse(BaseModel): # Indicates why the agent stopped processing the turn. @@ -3570,6 +4081,11 @@ class PromptResponse(BaseModel): ), ] = None + @field_validator("usage", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class NesJumpSuggestionVariant(NesJumpSuggestion): kind: Literal["jump"] @@ -3628,6 +4144,11 @@ class Error(BaseModel): ), ] = None + @field_validator("data", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class AgentPlanRemovedUpdate(PlanRemoved): session_update: Annotated[Literal["plan_removed"], Field(alias="sessionUpdate")] @@ -3699,6 +4220,11 @@ class Plan(BaseModel): ), ] = None + @field_validator("entries", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class PlanUpdateFile(PlanFile): type: Literal["file"] @@ -3710,7 +4236,7 @@ class PlanUpdateMarkdown(PlanMarkdown): class PlanItems(BaseModel): # The plan ID to update. - id: Annotated[str, Field(description="The plan ID to update.")] + plan_id: Annotated[str, Field(alias="planId", description="The plan ID to update.")] # The list of tasks to be accomplished. # # When updating an item-based plan, the agent must send a complete list of all entries @@ -3734,6 +4260,11 @@ class PlanItems(BaseModel): ), ] = None + @field_validator("entries", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class AvailableCommandInput(RootModel[UnstructuredCommandInput]): # The input specification for a command. @@ -3746,14 +4277,14 @@ class AvailableCommandInput(RootModel[UnstructuredCommandInput]): class SessionConfigOptionsCapabilities(BaseModel): # Whether the client supports boolean session configuration options. # - # Omitted or `null` means the client does not advertise support. + # Optional. Omitted or `null` both mean the client does not advertise support. # Supplying `{}` means agents may include `type: "boolean"` entries in # `configOptions`, and the client may send `session/set_config_option` # requests with `type: "boolean"` and a boolean `value`. boolean: Annotated[ Optional[BooleanConfigOptionCapabilities], Field( - description='Whether the client supports boolean session configuration options.\n\nOmitted or `null` means the client does not advertise support.\nSupplying `{}` means agents may include `type: "boolean"` entries in\n`configOptions`, and the client may send `session/set_config_option`\nrequests with `type: "boolean"` and a boolean `value`.' + description='Whether the client supports boolean session configuration options.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means agents may include `type: "boolean"` entries in\n`configOptions`, and the client may send `session/set_config_option`\nrequests with `type: "boolean"` and a boolean `value`.' ), ] = None # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -3769,17 +4300,32 @@ class SessionConfigOptionsCapabilities(BaseModel): ), ] = None + @field_validator("boolean", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class ElicitationCapabilities(BaseModel): # Whether the client supports form-based elicitation. + # + # Optional. Omitted or `null` both mean the client does not advertise support. + # Supplying `{}` means the client supports form-based elicitation. form: Annotated[ Optional[ElicitationFormCapabilities], - Field(description="Whether the client supports form-based elicitation."), + Field( + description="Whether the client supports form-based elicitation.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means the client supports form-based elicitation." + ), ] = None # Whether the client supports URL-based elicitation. + # + # Optional. Omitted or `null` both mean the client does not advertise support. + # Supplying `{}` means the client supports URL-based elicitation. url: Annotated[ Optional[ElicitationUrlCapabilities], - Field(description="Whether the client supports URL-based elicitation."), + Field( + description="Whether the client supports URL-based elicitation.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means the client supports URL-based elicitation." + ), ] = None # The _meta property is reserved by ACP to allow clients and agents to attach additional # metadata to their interactions. Implementations MUST NOT make assumptions about values at @@ -3794,6 +4340,11 @@ class ElicitationCapabilities(BaseModel): ), ] = None + @field_validator("form", "url", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class ClientNesCapabilities(BaseModel): # Whether the client supports the `jump` suggestion kind. @@ -3827,6 +4378,11 @@ class ClientNesCapabilities(BaseModel): ), ] = None + @field_validator("jump", "rename", "search_and_replace", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class HttpMcpServer(McpServerHttp): type: Literal["http"] @@ -3849,8 +4405,11 @@ class LoadSessionRequest(BaseModel): description="List of MCP servers to connect to for this session.", ), ] - # The working directory for this session. - cwd: Annotated[str, Field(description="The working directory for this session.")] + # The working directory for this session. Must be an absolute path. + cwd: Annotated[ + str, + Field(description="The working directory for this session. Must be an absolute path."), + ] # Additional workspace roots to activate for this session. Each path must be absolute. # # When omitted or empty, no additional roots are activated. When non-empty, @@ -3879,12 +4438,25 @@ class LoadSessionRequest(BaseModel): ), ] = None + @field_validator("additional_directories", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + + @field_validator("mcp_servers", mode="wrap") + @classmethod + def _skip_invalid_items_1(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class ForkSessionRequest(BaseModel): # The ID of the session to fork. session_id: Annotated[str, Field(alias="sessionId", description="The ID of the session to fork.")] - # The working directory for this session. - cwd: Annotated[str, Field(description="The working directory for this session.")] + # The working directory for this session. Must be an absolute path. + cwd: Annotated[ + str, + Field(description="The working directory for this session. Must be an absolute path."), + ] # Additional workspace roots to activate for this session. Each path must be absolute. # # When omitted or empty, no additional roots are activated. When non-empty, @@ -3918,12 +4490,25 @@ class ForkSessionRequest(BaseModel): ), ] = None + @field_validator("additional_directories", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + + @field_validator("mcp_servers", mode="wrap") + @classmethod + def _skip_invalid_items_1(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class ResumeSessionRequest(BaseModel): # The ID of the session to resume. session_id: Annotated[str, Field(alias="sessionId", description="The ID of the session to resume.")] - # The working directory for this session. - cwd: Annotated[str, Field(description="The working directory for this session.")] + # The working directory for this session. Must be an absolute path. + cwd: Annotated[ + str, + Field(description="The working directory for this session. Must be an absolute path."), + ] # Additional workspace roots to activate for this session. Each path must be absolute. # # When omitted or empty, no additional roots are activated. When non-empty, @@ -3958,6 +4543,16 @@ class ResumeSessionRequest(BaseModel): ), ] = None + @field_validator("additional_directories", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + + @field_validator("mcp_servers", mode="wrap") + @classmethod + def _skip_invalid_items_1(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class StartNesRequest(BaseModel): # The root URI of the workspace. @@ -3988,6 +4583,11 @@ class StartNesRequest(BaseModel): ), ] = None + @field_validator("repository", "workspace_uri", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class NesRelatedSnippet(BaseModel): # The URI of the file containing the snippets. @@ -4040,6 +4640,11 @@ class NesOpenFile(BaseModel): ), ] = None + @field_validator("last_focused_ms", "visible_range", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class NesDiagnostic(BaseModel): # The URI of the file containing the diagnostic. @@ -4101,6 +4706,11 @@ class TerminalOutputResponse(BaseModel): ), ] = None + @field_validator("exit_status", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class AcceptElicitationResponse(ElicitationAcceptAction): # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -4200,6 +4810,11 @@ class RejectNesNotification(BaseModel): ), ] = None + @field_validator("reason", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class TextContentBlock(TextContent): type: Literal["text"] @@ -4243,40 +4858,8 @@ class Content(BaseModel): ] = None -class MultiSelectPropertySchema(BaseModel): - # Optional title for the property. - title: Annotated[Optional[str], Field(description="Optional title for the property.")] = None - # Human-readable description. - description: Annotated[Optional[str], Field(description="Human-readable description.")] = None - # Minimum number of items to select. - min_items: Annotated[ - Optional[int], - Field(alias="minItems", description="Minimum number of items to select.", ge=0), - ] = None - # Maximum number of items to select. - max_items: Annotated[ - Optional[int], - Field(alias="maxItems", description="Maximum number of items to select.", ge=0), - ] = None - # The items definition describing allowed values. - items: Annotated[ - Union[UntitledMultiSelectItems, TitledMultiSelectItems], - Field(description="The items definition describing allowed values."), - ] - # Default selected values. - default: Annotated[Optional[List[str]], Field(description="Default selected values.")] = None - # The _meta property is reserved by ACP to allow clients and agents to attach additional - # metadata to their interactions. Implementations MUST NOT make assumptions about values at - # these keys. - # - # See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - field_meta: Annotated[ - Optional[Dict[str, Any]], - Field( - alias="_meta", - description="The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", - ), - ] = None +class ElicitationMultiSelectPropertySchema(MultiSelectPropertySchema): + type: Literal["array"] class AgentErrorMessage(BaseModel): @@ -4343,6 +4926,11 @@ class NesDocumentEventCapabilities(BaseModel): ), ] = None + @field_validator("did_change", "did_close", "did_focus", "did_open", "did_save", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class ListProvidersResponse(BaseModel): # Configurable providers with current routing info suitable for UI display. @@ -4421,6 +5009,11 @@ class NesEditSuggestion(BaseModel): ), ] = None + @field_validator("cursor_position", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class AgentPlanUpdate(Plan): session_update: Annotated[Literal["plan"], Field(alias="sessionUpdate")] @@ -4458,6 +5051,11 @@ class ContentChunk(BaseModel): ), ] = None + @field_validator("message_id", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class PlanUpdateItems(PlanItems): type: Literal["items"] @@ -4509,6 +5107,11 @@ class AvailableCommand(BaseModel): ), ] = None + @field_validator("input", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class _AvailableCommandsUpdate(BaseModel): # Commands the agent can execute @@ -4529,17 +5132,22 @@ class _AvailableCommandsUpdate(BaseModel): ), ] = None + @field_validator("available_commands", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class ClientSessionCapabilities(BaseModel): # Config option capabilities supported by the client. # - # Omitted or `null` means the client does not advertise support for any + # Omitted or `null` both mean the client does not advertise support for any # config option extensions. config_options: Annotated[ Optional[SessionConfigOptionsCapabilities], Field( alias="configOptions", - description="Config option capabilities supported by the client.\n\nOmitted or `null` means the client does not advertise support for any\nconfig option extensions.", + description="Config option capabilities supported by the client.\n\nOmitted or `null` both mean the client does not advertise support for any\nconfig option extensions.", ), ] = None # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -4555,6 +5163,11 @@ class ClientSessionCapabilities(BaseModel): ), ] = None + @field_validator("config_options", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class NewSessionRequest(BaseModel): # The working directory for this session. Must be an absolute path. @@ -4595,6 +5208,16 @@ class NewSessionRequest(BaseModel): ), ] = None + @field_validator("additional_directories", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + + @field_validator("mcp_servers", mode="wrap") + @classmethod + def _skip_invalid_items_1(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class PromptRequest(BaseModel): # The ID of the session to send this user message to @@ -4745,13 +5368,103 @@ class DidChangeDocumentNotification(BaseModel): ), ] = None + @field_validator("content_changes", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class ContentToolCallContent(Content): type: Literal["content"] -class ElicitationMultiSelectPropertySchema(MultiSelectPropertySchema): - type: Literal["array"] +class ElicitationSchema(BaseModel): + # Type discriminator. Always `"object"`. + type: Annotated[Optional[str], Field(description='Type discriminator. Always `"object"`.')] = "object" + # Optional title for the schema. + title: Annotated[Optional[str], Field(description="Optional title for the schema.")] = None + # Property definitions (must be primitive types). + properties: Annotated[ + Optional[ + Dict[ + str, + Union[ + ElicitationStringPropertySchema, + ElicitationNumberPropertySchema, + ElicitationIntegerPropertySchema, + ElicitationBooleanPropertySchema, + ElicitationMultiSelectPropertySchema, + ElicitationOtherPropertySchema, + ], + ] + ], + Field(description="Property definitions (must be primitive types)."), + ] = {} + # List of required property names. + required: Annotated[Optional[List[str]], Field(description="List of required property names.")] = None + # Optional description of what this schema represents. + description: Annotated[ + Optional[str], + Field(description="Optional description of what this schema represents."), + ] = None + # The _meta property is reserved by ACP to allow clients and agents to attach additional + # metadata to their interactions. Implementations MUST NOT make assumptions about values at + # these keys. + # + # See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + field_meta: Annotated[ + Optional[Dict[str, Any]], + Field( + alias="_meta", + description="The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + ), + ] = None + + @field_validator("type", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: "object") + + @field_validator("description", "title", mode="wrap") + @classmethod + def _salvage_on_error_1(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + +class ElicitationFormSessionMode(ElicitationSessionScope): + # A JSON Schema describing the form fields to present to the user. + requested_schema: Annotated[ + ElicitationSchema, + Field( + alias="requestedSchema", + description="A JSON Schema describing the form fields to present to the user.", + ), + ] + + +class ElicitationFormRequestMode(ElicitationRequestScope): + # A JSON Schema describing the form fields to present to the user. + requested_schema: Annotated[ + ElicitationSchema, + Field( + alias="requestedSchema", + description="A JSON Schema describing the form fields to present to the user.", + ), + ] + + +class ElicitationFormMode(RootModel[Union[ElicitationFormSessionMode, ElicitationFormRequestMode]]): + # **UNSTABLE** + # + # This capability is not part of the spec yet, and may be removed or changed at any point. + # + # Form-based elicitation mode where the client renders a form from the provided schema. + root: Annotated[ + Union[ElicitationFormSessionMode, ElicitationFormRequestMode], + Field( + description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation mode where the client renders a form from the provided schema." + ), + ] class NesEventCapabilities(BaseModel): @@ -4773,6 +5486,11 @@ class NesEventCapabilities(BaseModel): ), ] = None + @field_validator("document", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class SessionConfigOptionSelect(SessionConfigSelect): # Unique identifier for the configuration option. @@ -4786,7 +5504,7 @@ class SessionConfigOptionSelect(SessionConfigSelect): ] = None # Optional semantic category for this option (UX only). category: Annotated[ - Optional[str], + Optional[Union[str, Dict[str, Any]]], Field(description="Optional semantic category for this option (UX only)."), ] = None # The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -4803,6 +5521,11 @@ class SessionConfigOptionSelect(SessionConfigSelect): ] = None type: Literal["select"] + @field_validator("category", "description", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class LoadSessionResponse(BaseModel): # Initial mode state if supported by the Agent @@ -4835,6 +5558,16 @@ class LoadSessionResponse(BaseModel): ), ] = None + @field_validator("modes", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("config_options", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class ForkSessionResponse(BaseModel): # Unique identifier for the newly created forked session. @@ -4875,6 +5608,16 @@ class ForkSessionResponse(BaseModel): ), ] = None + @field_validator("modes", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("config_options", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class ResumeSessionResponse(BaseModel): # Initial mode state if supported by the Agent @@ -4907,6 +5650,16 @@ class ResumeSessionResponse(BaseModel): ), ] = None + @field_validator("modes", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("config_options", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class SetSessionConfigOptionResponse(BaseModel): # The full set of configuration options and their current values. @@ -4930,6 +5683,11 @@ class SetSessionConfigOptionResponse(BaseModel): ), ] = None + @field_validator("config_options", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class NesEditSuggestionVariant(NesEditSuggestion): kind: Literal["edit"] @@ -5013,6 +5771,21 @@ class ToolCall(BaseModel): ), ] = None + @field_validator("kind", "raw_input", "raw_output", "status", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("content", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + + @field_validator("locations", mode="wrap") + @classmethod + def _skip_invalid_items_1(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class _ConfigOptionUpdate(BaseModel): # The full set of configuration options and their current values. @@ -5036,6 +5809,11 @@ class _ConfigOptionUpdate(BaseModel): ), ] = None + @field_validator("config_options", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class ClientCapabilities(BaseModel): # File system capabilities supported by the client. @@ -5051,15 +5829,14 @@ class ClientCapabilities(BaseModel): Optional[bool], Field(description="Whether the Client support all `terminal/*` methods."), ] = False - # **UNSTABLE** - # - # This capability is not part of the spec yet, and may be removed or changed at any point. - # # Session-related capabilities supported by the client. + # + # Optional. Omitted or `null` both mean the client does not advertise any + # session-related extensions. session: Annotated[ Optional[ClientSessionCapabilities], Field( - description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nSession-related capabilities supported by the client." + description="Session-related capabilities supported by the client.\n\nOptional. Omitted or `null` both mean the client does not advertise any\nsession-related extensions." ), ] = None # **UNSTABLE** @@ -5068,12 +5845,12 @@ class ClientCapabilities(BaseModel): # # Whether the client supports `plan_update` and `plan_removed` session updates. # - # Optional. Omitted means the client does not advertise support. + # Optional. Omitted or `null` both mean the client does not advertise support. # Supplying `{}` means the client can receive both update types. plan: Annotated[ Optional[PlanCapabilities], Field( - description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client supports `plan_update` and `plan_removed` session updates.\n\nOptional. Omitted means the client does not advertise support.\nSupplying `{}` means the client can receive both update types." + description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nWhether the client supports `plan_update` and `plan_removed` session updates.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means the client can receive both update types." ), ] = None # **UNSTABLE** @@ -5095,10 +5872,13 @@ class ClientCapabilities(BaseModel): # # Elicitation capabilities supported by the client. # Determines which elicitation modes the agent may use. + # + # Optional. Omitted or `null` both mean the client does not advertise + # elicitation support. elicitation: Annotated[ Optional[ElicitationCapabilities], Field( - description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use." + description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nElicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use.\n\nOptional. Omitted or `null` both mean the client does not advertise\nelicitation support." ), ] = None # **UNSTABLE** @@ -5106,10 +5886,13 @@ class ClientCapabilities(BaseModel): # This capability is not part of the spec yet, and may be removed or changed at any point. # # NES (Next Edit Suggestions) capabilities supported by the client. + # + # Optional. Omitted or `null` both mean the client does not advertise any + # NES suggestion-kind extensions. nes: Annotated[ Optional[ClientNesCapabilities], Field( - description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNES (Next Edit Suggestions) capabilities supported by the client." + description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNES (Next Edit Suggestions) capabilities supported by the client.\n\nOptional. Omitted or `null` both mean the client does not advertise any\nNES suggestion-kind extensions." ), ] = None # **UNSTABLE** @@ -5137,6 +5920,31 @@ class ClientCapabilities(BaseModel): ), ] = None + @field_validator("terminal", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: False) + + @field_validator("elicitation", "nes", "plan", "session", mode="wrap") + @classmethod + def _salvage_on_error_1(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("fs", mode="wrap") + @classmethod + def _salvage_on_error_2(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: {"readTextFile": False, "writeTextFile": False}) + + @field_validator("auth", mode="wrap") + @classmethod + def _salvage_on_error_3(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: {"terminal": False}) + + @field_validator("position_encodings", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class SuggestNesRequest(BaseModel): # The session ID for this request. @@ -5196,6 +6004,7 @@ class ClientResponseMessage(BaseModel): AcceptElicitationResponse, DeclineElicitationResponse, CancelElicitationResponse, + OtherElicitationResponse, ], Any, ], @@ -5273,35 +6082,28 @@ class ToolCallUpdate(BaseModel): ), ] = None + @field_validator("kind", "raw_input", "raw_output", "status", "title", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) -class ElicitationSchema(BaseModel): - # Type discriminator. Always `"object"`. - type: Annotated[Optional[str], Field(description='Type discriminator. Always `"object"`.')] = "object" - # Optional title for the schema. - title: Annotated[Optional[str], Field(description="Optional title for the schema.")] = None - # Property definitions (must be primitive types). - properties: Annotated[ - Optional[ - Dict[ - str, - Union[ - ElicitationStringPropertySchema, - ElicitationNumberPropertySchema, - ElicitationIntegerPropertySchema, - ElicitationBooleanPropertySchema, - ElicitationMultiSelectPropertySchema, - ], - ] - ], - Field(description="Property definitions (must be primitive types)."), - ] = {} - # List of required property names. - required: Annotated[Optional[List[str]], Field(description="List of required property names.")] = None - # Optional description of what this schema represents. - description: Annotated[ - Optional[str], - Field(description="Optional description of what this schema represents."), - ] = None + @field_validator("content", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + + @field_validator("locations", mode="wrap") + @classmethod + def _skip_invalid_items_1(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + + +class CreateFormSessionElicitationRequest(ElicitationSessionScope): + # A human-readable message describing what input is needed. + message: Annotated[ + str, + Field(description="A human-readable message describing what input is needed."), + ] # The _meta property is reserved by ACP to allow clients and agents to attach additional # metadata to their interactions. Implementations MUST NOT make assumptions about values at # these keys. @@ -5314,9 +6116,7 @@ class ElicitationSchema(BaseModel): description="The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", ), ] = None - - -class ElicitationFormSessionMode(ElicitationSessionScope): + mode: Literal["form"] # A JSON Schema describing the form fields to present to the user. requested_schema: Annotated[ ElicitationSchema, @@ -5327,7 +6127,25 @@ class ElicitationFormSessionMode(ElicitationSessionScope): ] -class ElicitationFormRequestMode(ElicitationRequestScope): +class CreateFormRequestElicitationRequest(ElicitationRequestScope): + # A human-readable message describing what input is needed. + message: Annotated[ + str, + Field(description="A human-readable message describing what input is needed."), + ] + # The _meta property is reserved by ACP to allow clients and agents to attach additional + # metadata to their interactions. Implementations MUST NOT make assumptions about values at + # these keys. + # + # See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + field_meta: Annotated[ + Optional[Dict[str, Any]], + Field( + alias="_meta", + description="The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + ), + ] = None + mode: Literal["form"] # A JSON Schema describing the form fields to present to the user. requested_schema: Annotated[ ElicitationSchema, @@ -5338,18 +6156,31 @@ class ElicitationFormRequestMode(ElicitationRequestScope): ] -class ElicitationFormMode(RootModel[Union[ElicitationFormSessionMode, ElicitationFormRequestMode]]): - # **UNSTABLE** - # - # This capability is not part of the spec yet, and may be removed or changed at any point. - # - # Form-based elicitation mode where the client renders a form from the provided schema. - root: Annotated[ - Union[ElicitationFormSessionMode, ElicitationFormRequestMode], - Field( - description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nForm-based elicitation mode where the client renders a form from the provided schema." - ), - ] +ElicitationMode = Union[ + ElicitationFormSessionMode, + ElicitationFormRequestMode, + ElicitationUrlSessionMode, + ElicitationUrlRequestMode, +] +CreateFormElicitationRequest = Union[ + CreateFormSessionElicitationRequest, + CreateFormRequestElicitationRequest, +] +CreateUrlElicitationRequest = Union[ + CreateUrlSessionElicitationRequest, + CreateUrlRequestElicitationRequest, +] +CreateElicitationRequest = Union[ + CreateFormElicitationRequest, + CreateUrlElicitationRequest, + CreateOtherElicitationRequest, +] +CreateElicitationResponse = Union[ + AcceptElicitationResponse, + DeclineElicitationResponse, + CancelElicitationResponse, + OtherElicitationResponse, +] class NesCapabilities(BaseModel): @@ -5376,6 +6207,11 @@ class NesCapabilities(BaseModel): ), ] = None + @field_validator("context", "events", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + class NewSessionResponse(BaseModel): # Unique identifier for the created session. @@ -5418,6 +6254,16 @@ class NewSessionResponse(BaseModel): ), ] = None + @field_validator("modes", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("config_options", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class SuggestNesResponse(BaseModel): # The list of suggestions. @@ -5514,6 +6360,24 @@ def _coerce_protocol_version(cls, value: Any) -> int: except (TypeError, ValueError): return 1 + @field_validator("client_info", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("client_capabilities", mode="wrap") + @classmethod + def _salvage_on_error_1(cls, value: Any, handler: Any) -> Any: + return salvage_on_error( + value, + handler, + lambda: { + "fs": {"readTextFile": False, "writeTextFile": False}, + "terminal": False, + "auth": {"terminal": False}, + }, + ) + class RequestPermissionRequest(BaseModel): # The session ID for this request. @@ -5545,89 +6409,6 @@ class RequestPermissionRequest(BaseModel): ] = None -class CreateFormSessionElicitationRequest(ElicitationSessionScope): - # A human-readable message describing what input is needed. - message: Annotated[ - str, - Field(description="A human-readable message describing what input is needed."), - ] - # The _meta property is reserved by ACP to allow clients and agents to attach additional - # metadata to their interactions. Implementations MUST NOT make assumptions about values at - # these keys. - # - # See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - field_meta: Annotated[ - Optional[Dict[str, Any]], - Field( - alias="_meta", - description="The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", - ), - ] = None - mode: Literal["form"] - # A JSON Schema describing the form fields to present to the user. - requested_schema: Annotated[ - ElicitationSchema, - Field( - alias="requestedSchema", - description="A JSON Schema describing the form fields to present to the user.", - ), - ] - - -class CreateFormRequestElicitationRequest(ElicitationRequestScope): - # A human-readable message describing what input is needed. - message: Annotated[ - str, - Field(description="A human-readable message describing what input is needed."), - ] - # The _meta property is reserved by ACP to allow clients and agents to attach additional - # metadata to their interactions. Implementations MUST NOT make assumptions about values at - # these keys. - # - # See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) - field_meta: Annotated[ - Optional[Dict[str, Any]], - Field( - alias="_meta", - description="The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", - ), - ] = None - mode: Literal["form"] - # A JSON Schema describing the form fields to present to the user. - requested_schema: Annotated[ - ElicitationSchema, - Field( - alias="requestedSchema", - description="A JSON Schema describing the form fields to present to the user.", - ), - ] - - -ElicitationMode = Union[ - ElicitationFormSessionMode, - ElicitationFormRequestMode, - ElicitationUrlSessionMode, - ElicitationUrlRequestMode, -] -CreateFormElicitationRequest = Union[ - CreateFormSessionElicitationRequest, - CreateFormRequestElicitationRequest, -] -CreateUrlElicitationRequest = Union[ - CreateUrlSessionElicitationRequest, - CreateUrlRequestElicitationRequest, -] -CreateElicitationRequest = Union[ - CreateFormElicitationRequest, - CreateUrlElicitationRequest, -] -CreateElicitationResponse = Union[ - AcceptElicitationResponse, - DeclineElicitationResponse, - CancelElicitationResponse, -] - - class AgentCapabilities(BaseModel): # Whether the agent supports `session/load`. load_session: Annotated[ @@ -5672,11 +6453,12 @@ class AgentCapabilities(BaseModel): # # Provider configuration capabilities supported by the agent. # - # By supplying `{}` it means that the agent supports provider configuration methods. + # Optional. Omitted or `null` both mean the agent does not advertise support. + # Supplying `{}` means the agent supports provider configuration methods. providers: Annotated[ Optional[ProvidersCapabilities], Field( - description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nProvider configuration capabilities supported by the agent.\n\nBy supplying `{}` it means that the agent supports provider configuration methods." + description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nProvider configuration capabilities supported by the agent.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports provider configuration methods." ), ] = None # **UNSTABLE** @@ -5684,10 +6466,13 @@ class AgentCapabilities(BaseModel): # This capability is not part of the spec yet, and may be removed or changed at any point. # # NES (Next Edit Suggestions) capabilities supported by the agent. + # + # Optional. Omitted or `null` both mean the agent does not advertise support + # for NES methods. nes: Annotated[ Optional[NesCapabilities], Field( - description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNES (Next Edit Suggestions) capabilities supported by the agent." + description="**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nNES (Next Edit Suggestions) capabilities supported by the agent.\n\nOptional. Omitted or `null` both mean the agent does not advertise support\nfor NES methods." ), ] = None # **UNSTABLE** @@ -5715,6 +6500,31 @@ class AgentCapabilities(BaseModel): ), ] = None + @field_validator("load_session", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: False) + + @field_validator("nes", "position_encoding", "providers", mode="wrap") + @classmethod + def _salvage_on_error_1(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("mcp_capabilities", mode="wrap") + @classmethod + def _salvage_on_error_2(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: {"http": False, "sse": False, "acp": False}) + + @field_validator("prompt_capabilities", mode="wrap") + @classmethod + def _salvage_on_error_3(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: {"image": False, "audio": False, "embeddedContext": False}) + + @field_validator("auth", "session_capabilities", mode="wrap") + @classmethod + def _salvage_on_error_4(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: {}) + class SessionNotification(BaseModel): # The ID of the session this update pertains to. @@ -5825,6 +6635,7 @@ class AgentRequest(BaseModel): CreateFormRequestElicitationRequest, CreateUrlSessionElicitationRequest, CreateUrlRequestElicitationRequest, + CreateOtherElicitationRequest, ], Any, ] @@ -5886,6 +6697,31 @@ class InitializeResponse(BaseModel): ), ] = None + @field_validator("agent_info", mode="wrap") + @classmethod + def _salvage_on_error_0(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + @field_validator("agent_capabilities", mode="wrap") + @classmethod + def _salvage_on_error_1(cls, value: Any, handler: Any) -> Any: + return salvage_on_error( + value, + handler, + lambda: { + "loadSession": False, + "promptCapabilities": {"image": False, "audio": False, "embeddedContext": False}, + "mcpCapabilities": {"http": False, "sse": False, "acp": False}, + "sessionCapabilities": {}, + "auth": {}, + }, + ) + + @field_validator("auth_methods", mode="wrap") + @classmethod + def _skip_invalid_items_0(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + class AgentNotification(BaseModel): # The notification method name. diff --git a/tests/test_deserialize.py b/tests/test_deserialize.py new file mode 100644 index 0000000..42188ff --- /dev/null +++ b/tests/test_deserialize.py @@ -0,0 +1,62 @@ +"""Regression tests for lenient deserialization restored by ``acp._deserialize`` and the +validators ``scripts/gen_schema.py`` injects: ``x-deserialize-default-on-error`` (salvage a +malformed field to its default) and ``x-deserialize-skip-invalid-items`` (drop bad array +items). Mirrors the TypeScript SDK's ``src/schema-deserialize.test.ts``. +""" + +from typing import Any + +from pydantic import BaseModel, field_validator + +from acp._deserialize import salvage_on_error, skip_invalid_items +from acp.schema import ReadTextFileRequest, ToolCallUpdate, WriteTextFileRequest + + +class _Salvage(BaseModel): + n: int | None = None + + @field_validator("n", mode="wrap") + @classmethod + def _v(cls, value: Any, handler: Any) -> Any: + return salvage_on_error(value, handler, lambda: None) + + +def test_salvage_on_error_replaces_invalid_value() -> None: + assert _Salvage.model_validate({"n": "not-an-int"}).n is None + assert _Salvage.model_validate({"n": 7}).n == 7 + + +class _Skip(BaseModel): + xs: list[int] = [] + + @field_validator("xs", mode="wrap") + @classmethod + def _v(cls, value: Any, handler: Any) -> Any: + return skip_invalid_items(value, handler) + + +def test_skip_invalid_items_drops_bad_entries() -> None: + assert _Skip.model_validate({"xs": [1, "bad", 3]}).xs == [1, 3] + + +def test_meta_is_salvaged_to_none() -> None: + request = WriteTextFileRequest.model_validate({ + "sessionId": "s", + "path": "/p", + "content": "x", + "_meta": "not-a-dict", + }) + assert request.field_meta is None + + +def test_default_on_error_field_is_salvaged() -> None: + salvaged = ReadTextFileRequest.model_validate({"sessionId": "s", "path": "/p", "line": "nan"}) + assert salvaged.line is None + kept = ReadTextFileRequest.model_validate({"sessionId": "s", "path": "/p", "line": 5}) + assert kept.line == 5 + + +def test_skip_invalid_items_on_generated_model() -> None: + good = {"type": "content", "content": {"type": "text", "text": "ok"}} + update = ToolCallUpdate.model_validate({"toolCallId": "t", "content": [good, {"bogus": 1}]}) + assert len(update.content or []) == 1 diff --git a/tests/test_elicitation_catchall.py b/tests/test_elicitation_catchall.py new file mode 100644 index 0000000..331185f --- /dev/null +++ b/tests/test_elicitation_catchall.py @@ -0,0 +1,83 @@ +"""Regression tests for the schema-v1.18 "custom or future" catch-all union idiom. + +These guard the codegen support in ``scripts/gen_schema.py``: unknown discriminator +values must resolve to the catch-all variant while preserving the raw payload, and +known values must still resolve to their specific typed variant. +""" + +import pytest +from pydantic import TypeAdapter, ValidationError + +from acp.client.router import _mode_from_create_elicitation_request +from acp.exceptions import RequestError +from acp.schema import ( + AcceptElicitationResponse, + CreateElicitationRequest, + CreateElicitationResponse, + CreateOtherElicitationRequest, + ElicitationOtherPropertySchema, + ElicitationStringPropertySchema, + OtherElicitationResponse, + OtherMultiSelectItems, + StringMultiSelectItems, + TitledMultiSelectItems, +) + +_RESPONSE = TypeAdapter(CreateElicitationResponse) +_REQUEST = TypeAdapter(CreateElicitationRequest) + + +def test_known_elicitation_response_resolves_to_specific_variant() -> None: + assert isinstance(_RESPONSE.validate_python({"action": "accept"}), AcceptElicitationResponse) + + +def test_custom_elicitation_response_falls_back_to_catchall() -> None: + parsed = _RESPONSE.validate_python({"action": "x-snooze", "until": "later"}) + assert isinstance(parsed, OtherElicitationResponse) + assert parsed.action == "x-snooze" + assert parsed.model_dump(by_alias=True)["until"] == "later" + + +def test_malformed_known_variant_is_rejected_not_catchall() -> None: + # A "form" request missing the required requestedSchema must fail validation rather + # than silently degrade to the catch-all (restores the schema's dropped `not` clause). + with pytest.raises(ValidationError): + _REQUEST.validate_python({"mode": "form", "message": "hi", "sessionId": "s1"}) + + +def test_custom_elicitation_request_preserves_mode_and_payload() -> None: + parsed = _REQUEST.validate_python({ + "mode": "x-voice", + "message": "speak now", + "sessionId": "sess-1", + "codec": "opus", + }) + assert isinstance(parsed, CreateOtherElicitationRequest) + assert parsed.mode == "x-voice" + assert parsed.model_dump(by_alias=True)["codec"] == "opus" + + +def test_unknown_elicitation_mode_dispatches_to_clean_request_error() -> None: + # A custom mode parses (above); the client router must then reject it with a clean + # RequestError (invalid params) rather than a bare TypeError that surfaces as an + # opaque -32603 internal error. + request = CreateOtherElicitationRequest(message="hi", mode="x-voice") + with pytest.raises(RequestError) as exc_info: + _mode_from_create_elicitation_request(request) + assert isinstance(exc_info.value, RequestError) + assert exc_info.value.code == -32602 + + +def test_elicitation_property_schema_catchall() -> None: + adapter = TypeAdapter(ElicitationStringPropertySchema | ElicitationOtherPropertySchema) + assert isinstance(adapter.validate_python({"type": "string"}), ElicitationStringPropertySchema) + custom = adapter.validate_python({"type": "x-slider", "min": 0, "max": 9}) + assert isinstance(custom, ElicitationOtherPropertySchema) + assert custom.model_dump(by_alias=True)["max"] == 9 + + +def test_multi_select_items_variants() -> None: + adapter = TypeAdapter(StringMultiSelectItems | OtherMultiSelectItems | TitledMultiSelectItems) + assert isinstance(adapter.validate_python({"type": "string", "enum": ["a", "b"]}), StringMultiSelectItems) + assert isinstance(adapter.validate_python({"anyOf": [{"const": "a", "title": "A"}]}), TitledMultiSelectItems) + assert isinstance(adapter.validate_python({"type": "x-chips", "note": "hi"}), OtherMultiSelectItems) diff --git a/tests/test_gen_all.py b/tests/test_gen_all.py index 2cc95b5..da9f73d 100644 --- a/tests/test_gen_all.py +++ b/tests/test_gen_all.py @@ -1,5 +1,12 @@ from scripts.gen_all import resolve_ref, schema_source_paths -from scripts.gen_schema import _preprocess_schema_for_codegen, _restore_required_nullable_fields +from scripts.gen_schema import ( + _deserialize_field_specs, + _extensible_union_excluded_tags, + _fallback_expression, + _normalize_catchall_unions, + _preprocess_schema_for_codegen, + _restore_required_nullable_fields, +) def test_resolve_ref_accepts_schema_release_tags() -> None: @@ -87,6 +94,84 @@ def test_codegen_preprocess_distributes_common_object_properties() -> None: assert request["oneOf"][1]["allOf"] == [{"$ref": "#/$defs/ScopeB"}] +def test_codegen_preprocess_normalizes_catchall_unions() -> None: + schema = { + "anyOf": [ + { + "type": "object", + "properties": {"type": {"type": "string", "const": "known"}}, + "required": ["type"], + }, + { + "title": "other", + "description": "Custom or future.", + "type": "object", + "properties": {"type": {"type": "string"}}, + "required": ["type"], + "not": {"anyOf": [{"const": "known"}]}, + "unevaluatedProperties": True, + }, + ], + "discriminator": {"propertyName": "type"}, + } + + normalized = _normalize_catchall_unions(schema) + + assert "discriminator" not in normalized + known, other = normalized["anyOf"] + assert known["properties"]["type"]["const"] == "known" + assert other["additionalProperties"] is True + assert other["properties"] == {"type": {"type": "string"}} + assert other["required"] == ["type"] + assert "not" not in other + assert "unevaluatedProperties" not in other + + +def test_extensible_union_excluded_tags_reads_not_clause() -> None: + union_def = { + "discriminator": {"propertyName": "action"}, + "anyOf": [ + {"properties": {"action": {"const": "accept"}}, "required": ["action"]}, + { + "title": "other", + "properties": {"action": {"type": "string"}}, + "not": { + "anyOf": [ + {"properties": {"action": {"const": "accept"}}}, + {"properties": {"action": {"const": "decline"}}}, + ] + }, + }, + ], + } + + assert _extensible_union_excluded_tags(union_def, "action") == ("accept", "decline") + + +def test_deserialize_field_specs_groups_by_fallback_and_excludes_meta() -> None: + definition = { + "required": ["items"], + "properties": { + "_meta": {"x-deserialize-default-on-error": True}, + "note": {"type": "string", "x-deserialize-default-on-error": True}, + "flag": {"type": "boolean", "default": False, "x-deserialize-default-on-error": True}, + "items": {"type": "array", "x-deserialize-skip-invalid-items": True}, + }, + } + + salvage, skip = _deserialize_field_specs(definition) + + assert salvage == {"lambda: None": ["note"], "lambda: False": ["flag"]} + assert skip == ["items"] + + +def test_fallback_expression_matches_schema_default_rules() -> None: + assert _fallback_expression({"default": False}, is_required=False) == "lambda: False" + assert _fallback_expression({"type": "array"}, is_required=True) == "lambda: []" + assert _fallback_expression({"type": ["array", "null"]}, is_required=False) == "lambda: None" + assert _fallback_expression({"type": "string"}, is_required=False) == "lambda: None" + + def test_codegen_postprocess_preserves_required_nullable_fields() -> None: schema = { "$defs": { From 3ddfa385fabd3123ab3ea46c5a203674a823143a Mon Sep 17 00:00:00 2001 From: Federico Ciner Date: Fri, 31 Jul 2026 02:43:09 +1000 Subject: [PATCH 2/4] Initial implementation of ACP web transport, with docs and examples (#118) --- AGENTS.md | 1 + docs/web-transport.md | 108 ++++++++ examples/http_client.py | 54 ++++ examples/http_server.py | 81 ++++++ examples/ws_client.py | 53 ++++ mkdocs.yml | 1 + pyproject.toml | 5 + src/acp/_cookies.py | 53 ++++ src/acp/_sse.py | 84 ++++++ src/acp/_transport.py | 151 +++++++++++ src/acp/agent/connection.py | 16 +- src/acp/client/connection.py | 16 +- src/acp/connection.py | 73 +++--- src/acp/core.py | 17 +- src/acp/http/__init__.py | 30 +++ src/acp/http/asgi.py | 175 +++++++++++++ src/acp/http/client.py | 238 +++++++++++++++++ src/acp/http/protocol.py | 106 ++++++++ src/acp/http/server.py | 461 +++++++++++++++++++++++++++++++++ src/acp/ws/__init__.py | 23 ++ src/acp/ws/client.py | 117 +++++++++ src/acp/ws/server.py | 79 ++++++ tests/http/conftest.py | 62 +++++ tests/http/test_cookies.py | 42 +++ tests/http/test_fixes.py | 186 +++++++++++++ tests/http/test_http_client.py | 213 +++++++++++++++ tests/http/test_http_server.py | 233 +++++++++++++++++ tests/http/test_loopback.py | 131 ++++++++++ tests/http/test_protocol.py | 55 ++++ tests/http/test_sse.py | 63 +++++ tests/http/test_websocket.py | 182 +++++++++++++ tests/http/test_ws_cookies.py | 55 ++++ tests/test_rpc.py | 4 +- uv.lock | 233 ++++++++++++++++- 34 files changed, 3343 insertions(+), 58 deletions(-) create mode 100644 docs/web-transport.md create mode 100644 examples/http_client.py create mode 100644 examples/http_server.py create mode 100644 examples/ws_client.py create mode 100644 src/acp/_cookies.py create mode 100644 src/acp/_sse.py create mode 100644 src/acp/_transport.py create mode 100644 src/acp/http/__init__.py create mode 100644 src/acp/http/asgi.py create mode 100644 src/acp/http/client.py create mode 100644 src/acp/http/protocol.py create mode 100644 src/acp/http/server.py create mode 100644 src/acp/ws/__init__.py create mode 100644 src/acp/ws/client.py create mode 100644 src/acp/ws/server.py create mode 100644 tests/http/conftest.py create mode 100644 tests/http/test_cookies.py create mode 100644 tests/http/test_fixes.py create mode 100644 tests/http/test_http_client.py create mode 100644 tests/http/test_http_server.py create mode 100644 tests/http/test_loopback.py create mode 100644 tests/http/test_protocol.py create mode 100644 tests/http/test_sse.py create mode 100644 tests/http/test_websocket.py create mode 100644 tests/http/test_ws_cookies.py diff --git a/AGENTS.md b/AGENTS.md index a2927e4..263c281 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ Use this page as the quick orientation for the Python SDK repo. It mirrors the t | Path | Why it exists | | --- | --- | | `src/acp/` | Runtime package: agent/client bases, transports, helpers, schema bindings, contrib utilities | +| `src/acp/http/`, `src/acp/ws/` | Experimental remote transports (Streamable HTTP + WebSocket), client + server; opt-in via the `[http]` extra | | `schema/` | Upstream JSON schema sources (regenerate with `make gen-all`) | | `examples/` | Runnable scripts such as `echo_agent.py`, `client.py`, `gemini.py`, `duet.py` | | `tests/` | Pytest suite, including optional Gemini smoke tests in `tests/test_gemini_example.py` | diff --git a/docs/web-transport.md b/docs/web-transport.md new file mode 100644 index 0000000..e601988 --- /dev/null +++ b/docs/web-transport.md @@ -0,0 +1,108 @@ +# Web Transport (Streamable HTTP & WebSocket) + +> **Experimental.** The remote web transports are experimental and may change. +> They ship as an optional extra and are import-guarded. + +The SDK can run ACP over two remote connectivity profiles in addition to stdio: + +- **Streamable HTTP** โ€” `POST` for clientโ†’server messages, long-lived `GET` SSE + streams for serverโ†’client messages (one connection-scoped stream plus one per + session), and `DELETE` to terminate. `initialize` returns `200 OK` with a JSON + body; all other POSTs return `202 Accepted`. **Requires HTTP/2.** +- **WebSocket** โ€” a `GET` upgrade on the same endpoint carrying full-duplex + JSON-RPC text frames. + +Both reuse the existing JSON-RPC message format and ACP lifecycle +(`initialize` โ†’ session methods โ†’ close). + +## Installation + +```bash +pip install "agent-client-protocol[http]" +``` + +This pulls in `httpx[http2]` (HTTP/2 + SSE consumption) and `websockets`. + +## Client + +Both transports produce a message-level `Transport` that plugs into the existing +`connect_to_agent`: + +```python +from acp import connect_to_agent +from acp.http import create_http_stream +from acp.ws import create_websocket_stream + +# Streamable HTTP +transport = create_http_stream("http://localhost:8000/acp") +conn = connect_to_agent(my_client, transport) + +# ...or WebSocket +transport = await create_websocket_stream("ws://localhost:8000/acp") +conn = connect_to_agent(my_client, transport) + +init = await conn.initialize(protocol_version=1) +session = await conn.new_session(cwd="/tmp", mcp_servers=[]) +await conn.prompt(session_id=session.session_id, prompt=[...]) +await conn.close() +await transport.close() +``` + +The client sends `initialize` first, reads the `Acp-Connection-Id` response +header, then opens the connection-scoped SSE stream. When a new `sessionId` +appears it opens that session-scoped stream too. A single SSE attempt is made per +stream; reconnect/retry is the caller's responsibility (v1 of the RFD). + +## Server + +The server core is framework-agnostic; a thin ASGI adapter bridges it to your +web framework: + +```python +from acp.http.asgi import create_asgi_app + +# One agent instance is created per connection. +app = create_asgi_app(lambda conn: MyAgent()) +``` + +`app` is a standard ASGI 3.0 application handling `POST`/`GET`/`DELETE` and +WebSocket upgrades on the ACP endpoint. + +### HTTP/2 server requirement + +> โš ๏ธ **Uvicorn does not serve HTTP/2.** For a spec-compliant Streamable HTTP +> server, run an HTTP/2-capable ASGI server (**Hypercorn**, Daphne, Granian) or +> terminate HTTP/2 at a proxy. The WebSocket profile works on Uvicorn. + +```python +import asyncio +import hypercorn.asyncio +from hypercorn.config import Config + +config = Config() +config.bind = ["localhost:8000"] +config.alpn_protocols = ["h2", "http/1.1"] +asyncio.run(hypercorn.asyncio.serve(app, config)) +``` + +## Examples + +- [`examples/http_server.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/http_server.py) โ€” serve an agent over HTTP + WS (Hypercorn). +- [`examples/http_client.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/http_client.py) โ€” connect over Streamable HTTP. +- [`examples/ws_client.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/ws_client.py) โ€” connect over WebSocket. + +## Identity model + +- `Acp-Connection-Id` (HTTP header) โ€” returned by `initialize`; required on all + post-initialize HTTP requests and every GET stream. +- `Acp-Session-Id` (HTTP header) โ€” required on session-scoped POSTs and the + session-scoped GET stream. +- `sessionId` (JSON-RPC field) โ€” carried in params/results and used to route + messages to the correct stream. + +## Not yet supported (deferred to a future revision) + +- `Last-Event-ID` / SSE resumability and message sequencing. +- Client-side automatic reconnect/backoff. +- Batch JSON-RPC (the server returns `501`). +- `Acp-Protocol-Version` header enforcement. diff --git a/examples/http_client.py b/examples/http_client.py new file mode 100644 index 0000000..1843c01 --- /dev/null +++ b/examples/http_client.py @@ -0,0 +1,54 @@ +# /// script +# requires-python = ">=3.10,<3.15" +# dependencies = [ +# "agent-client-protocol[http]", +# ] +# /// +"""Connect to a remote ACP agent over Streamable HTTP (experimental). + +Start the server first (``uv run examples/http_server.py``), then run this. +""" + +import asyncio +from typing import Any + +from acp import connect_to_agent, text_block +from acp.http import create_http_stream +from acp.interfaces import Client + + +class ExampleClient(Client): + async def request_permission(self, session_id: str, tool_call: Any, options: Any, **kwargs: Any) -> Any: + # Auto-allow the first option. + return {"outcome": {"outcome": "selected", "optionId": options[0]["optionId"]}} + + async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None: + content = getattr(update, "content", None) + text = getattr(content, "text", None) if content is not None else None + if text: + print(f"<< {text}") + + async def write_text_file(self, *args: Any, **kwargs: Any) -> None: + return None + + async def read_text_file(self, *args: Any, **kwargs: Any) -> Any: + return {"content": ""} + + +async def main() -> None: + transport = create_http_stream("http://localhost:8000/acp") + conn = connect_to_agent(ExampleClient(), transport) + try: + init = await conn.initialize(protocol_version=1) + print(f"initialized (protocol v{init.protocol_version})") + session = await conn.new_session(cwd=".", mcp_servers=[]) + print(f"session: {session.session_id}") + result = await conn.prompt(session_id=session.session_id, prompt=[text_block("hello over http")]) + print(f"stop reason: {result.stop_reason}") + finally: + await conn.close() + await transport.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/http_server.py b/examples/http_server.py new file mode 100644 index 0000000..98b4fc7 --- /dev/null +++ b/examples/http_server.py @@ -0,0 +1,81 @@ +# /// script +# requires-python = ">=3.10,<3.15" +# dependencies = [ +# "agent-client-protocol[http]", +# "hypercorn>=0.17", +# ] +# /// +"""Serve an ACP agent over Streamable HTTP + WebSocket (experimental). + +Run with an HTTP/2-capable ASGI server for spec-compliant Streamable HTTP. This +example uses Hypercorn; Uvicorn works for WebSocket but does not serve HTTP/2. + + uv run examples/http_server.py + # then, in another terminal: + uv run examples/http_client.py + uv run examples/ws_client.py +""" + +import asyncio +from typing import Any +from uuid import uuid4 + +from acp import ( + Agent, + InitializeResponse, + NewSessionResponse, + PromptResponse, + text_block, + update_agent_message, +) +from acp.http.asgi import create_asgi_app +from acp.interfaces import Client +from acp.schema import ClientCapabilities, Implementation + + +class EchoAgent(Agent): + _conn: Client + + def on_connect(self, conn: Client) -> None: + self._conn = conn + + async def initialize( + self, + protocol_version: int, + client_capabilities: ClientCapabilities | None = None, + client_info: Implementation | None = None, + **kwargs: Any, + ) -> InitializeResponse: + return InitializeResponse(protocol_version=protocol_version) + + async def new_session(self, cwd: str = "", **kwargs: Any) -> NewSessionResponse: + return NewSessionResponse(session_id=uuid4().hex) + + async def prompt(self, session_id: str, prompt: list[Any], **kwargs: Any) -> PromptResponse: + for block in prompt: + text = block.get("text", "") if isinstance(block, dict) else getattr(block, "text", "") + await self._conn.session_update( + session_id=session_id, + update=update_agent_message(text_block(f"echo: {text}")), + ) + return PromptResponse(stop_reason="end_turn") + + +# One agent instance per connection. +app = create_asgi_app(lambda conn: EchoAgent()) + + +async def main() -> None: + import hypercorn.asyncio + from hypercorn.config import Config + + config = Config() + config.bind = ["localhost:8000"] + # Enable HTTP/2 (Streamable HTTP requires it). Hypercorn negotiates h2c/h2. + config.alpn_protocols = ["h2", "http/1.1"] + print("Serving ACP agent on http://localhost:8000/acp (HTTP + WS)") + await hypercorn.asyncio.serve(app, config) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/ws_client.py b/examples/ws_client.py new file mode 100644 index 0000000..ac9c05c --- /dev/null +++ b/examples/ws_client.py @@ -0,0 +1,53 @@ +# /// script +# requires-python = ">=3.10,<3.15" +# dependencies = [ +# "agent-client-protocol[http]", +# ] +# /// +"""Connect to a remote ACP agent over WebSocket (experimental). + +Start the server first (``uv run examples/http_server.py``), then run this. +""" + +import asyncio +from typing import Any + +from acp import connect_to_agent, text_block +from acp.interfaces import Client +from acp.ws import create_websocket_stream + + +class ExampleClient(Client): + async def request_permission(self, session_id: str, tool_call: Any, options: Any, **kwargs: Any) -> Any: + return {"outcome": {"outcome": "selected", "optionId": options[0]["optionId"]}} + + async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None: + content = getattr(update, "content", None) + text = getattr(content, "text", None) if content is not None else None + if text: + print(f"<< {text}") + + async def write_text_file(self, *args: Any, **kwargs: Any) -> None: + return None + + async def read_text_file(self, *args: Any, **kwargs: Any) -> Any: + return {"content": ""} + + +async def main() -> None: + transport = await create_websocket_stream("ws://localhost:8000/acp") + conn = connect_to_agent(ExampleClient(), transport) + try: + init = await conn.initialize(protocol_version=1) + print(f"initialized (protocol v{init.protocol_version})") + session = await conn.new_session(cwd=".", mcp_servers=[]) + print(f"session: {session.session_id}") + result = await conn.prompt(session_id=session.session_id, prompt=[text_block("hello over websocket")]) + print(f"stop reason: {result.stop_reason}") + finally: + await conn.close() + await transport.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mkdocs.yml b/mkdocs.yml index 09e3ea4..f7e1b6f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,6 +11,7 @@ nav: - Home: index.md - Quick Start: quickstart.md - Use Cases: use-cases.md + - Web Transport (HTTP/WS): web-transport.md - Experimental Contrib: contrib.md - Releasing: releasing.md - 0.11 Migration Guide: migration-guide-0.11.md diff --git a/pyproject.toml b/pyproject.toml index 04117f4..220b908 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,10 +44,15 @@ dev = [ "mkdocstrings[python]>=0.26.1", "python-dotenv>=1.1.1", "prek>=0.2.17", + "httpx[http2]>=0.27", + "websockets>=12.0", + "uvicorn>=0.30", ] [project.optional-dependencies] logfire = ["logfire>=0.14", "opentelemetry-sdk>=1.28.0"] +# Experimental remote transports (Streamable HTTP + WebSocket), client + server. +http = ["httpx[http2]>=0.27", "websockets>=12.0"] [build-system] requires = ["pdm-backend"] diff --git a/src/acp/_cookies.py b/src/acp/_cookies.py new file mode 100644 index 0000000..a4b05d6 --- /dev/null +++ b/src/acp/_cookies.py @@ -0,0 +1,53 @@ +"""In-memory cookie store for the WebSocket handshake. + +The HTTP client relies on ``httpx``'s built-in cookie jar for session affinity, +but the WebSocket handshake needs a small, explicit store to collect +``Set-Cookie`` headers from the upgrade response and echo them back as a +``Cookie`` request header for the socket lifetime. + +This is intentionally minimal: it stores nameโ†’value pairs without attribute +parsing (domain/path/expiry), matching the affinity-only use case in the RFD. +""" + +from __future__ import annotations + +__all__ = ["MemoryAcpCookieStore"] + + +class MemoryAcpCookieStore: + """A tiny nameโ†’value cookie store keyed by cookie name.""" + + def __init__(self) -> None: + self._cookies: dict[str, str] = {} + + def store_set_cookie(self, header_value: str) -> None: + """Ingest a single ``Set-Cookie`` header value. + + Only the leading ``name=value`` pair is retained; cookie attributes + (``; Path=/``, ``; HttpOnly`` etc.) are ignored. + """ + first = header_value.split(";", 1)[0].strip() + if not first or "=" not in first: + return + name, _, value = first.partition("=") + name = name.strip() + if name: + self._cookies[name] = value.strip() + + def store_set_cookies(self, header_values: list[str]) -> None: + """Ingest multiple ``Set-Cookie`` header values.""" + for value in header_values: + self.store_set_cookie(value) + + def cookie_header(self) -> str | None: + """Render the stored cookies as a ``Cookie`` request header value.""" + if not self._cookies: + return None + return "; ".join(f"{name}={value}" for name, value in self._cookies.items()) + + def clear(self) -> None: + """Drop all stored cookies.""" + self._cookies.clear() + + def __len__(self) -> int: + return len(self._cookies) diff --git a/src/acp/_sse.py b/src/acp/_sse.py new file mode 100644 index 0000000..455a2d2 --- /dev/null +++ b/src/acp/_sse.py @@ -0,0 +1,84 @@ +"""Server-Sent Events (SSE) serialization + parsing building blocks. + +Minimal helpers shared by the Streamable HTTP client and server. We only need +the ``data:`` field (JSON-RPC payloads) plus keepalive comments; ``event:``, +``id:``, and ``retry:`` are not used by this transport (resumability is v2). +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +__all__ = [ + "parse_sse_stream", + "serialize_sse_event", + "serialize_sse_keepalive", +] + + +def serialize_sse_event(message: dict[str, Any]) -> bytes: + """Serialize a JSON-RPC message as an SSE ``data:`` event. + + The payload is JSON-encoded on a single line and terminated by a blank line, + per the SSE framing rules. + """ + data = json.dumps(message, separators=(",", ":")) + return f"data: {data}\n\n".encode() + + +def serialize_sse_keepalive() -> bytes: + """Serialize an SSE comment used to keep the connection alive.""" + return b": keepalive\n\n" + + +def _decode_event(data_lines: list[str]) -> dict[str, Any] | None: + """Decode buffered ``data:`` lines into a JSON object, or None to skip.""" + payload = "\n".join(data_lines) + if not payload: + return None + try: + return json.loads(payload) + except json.JSONDecodeError: + return None + + +def _append_field(line: str, data_lines: list[str]) -> None: + """Append a ``data:`` field's value to the buffer; ignore other fields.""" + field, _, value = line.partition(":") + if value.startswith(" "): + value = value[1:] + if field == "data": + data_lines.append(value) + + +async def parse_sse_stream(chunks: AsyncIterator[bytes]) -> AsyncIterator[dict[str, Any]]: + """Parse an SSE byte stream, yielding decoded JSON-RPC ``data:`` payloads. + + Comments (lines starting with ``:``) and non-``data`` fields are ignored. + Multi-line ``data:`` fields are concatenated with newlines per the spec. A + blank line dispatches the buffered event. + """ + buffer = "" + data_lines: list[str] = [] + + async for chunk in chunks: + buffer += chunk.decode("utf-8") + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.rstrip("\r") + if line == "": + event = _decode_event(data_lines) + data_lines = [] + if event is not None: + yield event + elif not line.startswith(":"): + _append_field(line, data_lines) + + # Flush a trailing event with no terminating blank line. + event = _decode_event(data_lines) + if event is not None: + yield event diff --git a/src/acp/_transport.py b/src/acp/_transport.py new file mode 100644 index 0000000..17c2815 --- /dev/null +++ b/src/acp/_transport.py @@ -0,0 +1,151 @@ +"""Message-level transport seam for :class:`acp.connection.Connection`. + +Historically the connection spoke directly to ``asyncio`` byte streams with +newline framing. To support message-oriented remote transports (Streamable +HTTP + WebSocket) we introduce a small :class:`Transport` protocol that moves +JSON-RPC *messages* (already-decoded ``dict`` payloads) instead of bytes. + +The existing stdio path is re-expressed on top of this seam via +:class:`NdjsonTransport`, which wraps the current byte-stream framing so there +is **zero behaviour change** for stdio users. :func:`memory_transport_pair` +gives two linked in-memory transports, used by the HTTP/WS server to bind an +``AgentSideConnection`` to its message pump. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Awaitable + + from .task import MessageSender + +__all__ = [ + "NdjsonTransport", + "Transport", + "memory_transport_pair", +] + + +@runtime_checkable +class Transport(Protocol): + """A bidirectional stream of JSON-RPC messages. + + Implementations move already-decoded ``dict`` payloads. ``receive`` returns + ``None`` to signal end-of-stream (EOF). ``send`` may raise if the message + could not be delivered (e.g. an HTTP POST failing before any JSON-RPC + response exists); callers correlate such failures with pending requests. + """ + + async def send(self, message: dict[str, Any]) -> None: ... + + async def receive(self) -> dict[str, Any] | None: ... + + async def close(self) -> None: ... + + +class NdjsonTransport: + """Transport backed by newline-delimited JSON over asyncio byte streams. + + This preserves the exact framing, buffer-limit-overrun handling, and receive + timeout semantics that previously lived inside ``Connection`` so that stdio + behaviour is byte-for-byte unchanged. + """ + + def __init__( + self, + reader: asyncio.StreamReader, + sender: MessageSender, + *, + receive_timeout: float | None = None, + ) -> None: + self._reader = reader + self._sender = sender + self._receive_timeout = receive_timeout + + async def send(self, message: dict[str, Any]) -> None: + await self._sender.send(message) + + async def receive(self) -> dict[str, Any] | None: + while True: + line = await self._read_line() + if not line: + return None + line = line.strip() + if not line: + continue + try: + message: dict[str, Any] = json.loads(line) + except Exception: + logging.exception("Error parsing JSON-RPC message") + continue + return message + + async def close(self) -> None: + await self._sender.close() + + async def _read_line(self) -> bytes: + chunks: list[bytes] = [] + try: + while True: + try: + line = await self._wait_for_reader(self._reader.readuntil(b"\n")) + except asyncio.LimitOverrunError as exc: + chunks.append(await self._wait_for_reader(self._reader.readexactly(exc.consumed))) + else: + chunks.append(line) + return b"".join(chunks) + except asyncio.IncompleteReadError as exc: + chunks.append(exc.partial) + return b"".join(chunks) + + async def _wait_for_reader(self, awaitable: Awaitable[bytes]) -> bytes: + return await asyncio.wait_for(awaitable, timeout=self._receive_timeout) + + +class _MemoryTransport: + """One end of an in-process :func:`memory_transport_pair`.""" + + def __init__( + self, + outbox: asyncio.Queue[dict[str, Any] | None], + inbox: asyncio.Queue[dict[str, Any] | None], + ) -> None: + self._outbox = outbox + self._inbox = inbox + self._closed = False + + async def send(self, message: dict[str, Any]) -> None: + if self._closed: + raise ConnectionError("Transport closed") + await self._outbox.put(dict(message)) + + async def receive(self) -> dict[str, Any] | None: + return await self._inbox.get() + + async def close(self) -> None: + if self._closed: + return + self._closed = True + with contextlib.suppress(Exception): + self._outbox.put_nowait(None) + + +def memory_transport_pair() -> tuple[Transport, Transport]: + """Return two linked in-memory transports. + + A message ``send`` on one end becomes available via ``receive`` on the + other. Closing an end enqueues an EOF (``None``) for its peer. This mirrors + the ``TransformStream`` pair the TypeScript SDK uses to bind a server-side + connection to its HTTP/WS message pump. + """ + a_to_b: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() + b_to_a: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() + left = _MemoryTransport(outbox=a_to_b, inbox=b_to_a) + right = _MemoryTransport(outbox=b_to_a, inbox=a_to_b) + return left, right diff --git a/src/acp/agent/connection.py b/src/acp/agent/connection.py index bd8c176..a3921e3 100644 --- a/src/acp/agent/connection.py +++ b/src/acp/agent/connection.py @@ -6,6 +6,7 @@ from pydantic import TypeAdapter +from .._transport import Transport from ..connection import Connection from ..interfaces import Agent, Client from ..meta import CLIENT_METHODS @@ -79,17 +80,24 @@ def __init__( self, to_agent: Callable[[Client], Agent] | Agent, input_stream: Any, - output_stream: Any, + output_stream: Any = None, listening: bool = True, *, use_unstable_protocol: bool = False, **connection_kwargs: Any, ) -> None: agent = to_agent(self) if callable(to_agent) else to_agent - if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance(output_stream, asyncio.StreamReader): - raise TypeError(_AGENT_CONNECTION_ERROR) handler = build_agent_router(cast(Agent, agent), use_unstable_protocol=use_unstable_protocol) - self._conn = Connection(handler, input_stream, output_stream, listening=listening, **connection_kwargs) + if isinstance(input_stream, Transport): + if output_stream is not None: + raise TypeError(_AGENT_CONNECTION_ERROR) + self._conn = Connection(handler, input_stream, listening=listening, **connection_kwargs) + else: + 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, listening=listening, **connection_kwargs) if on_connect := getattr(agent, "on_connect", None): on_connect(self) diff --git a/src/acp/client/connection.py b/src/acp/client/connection.py index 0f3b1cf..81f6769 100644 --- a/src/acp/client/connection.py +++ b/src/acp/client/connection.py @@ -4,6 +4,7 @@ from collections.abc import Callable from typing import Any, cast, final +from .._transport import Transport from ..connection import Connection from ..interfaces import Agent, Client from ..meta import AGENT_METHODS @@ -62,16 +63,23 @@ def __init__( self, to_client: Callable[[Agent], Client] | Client, input_stream: Any, - output_stream: Any, + output_stream: Any = None, *, use_unstable_protocol: bool = False, **connection_kwargs: 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) if callable(to_client) else to_client handler = build_client_router(cast(Client, client), use_unstable_protocol=use_unstable_protocol) - self._conn = Connection(handler, input_stream, output_stream, **connection_kwargs) + if isinstance(input_stream, Transport): + if output_stream is not None: + raise TypeError(_CLIENT_CONNECTION_ERROR) + self._conn = Connection(handler, input_stream, **connection_kwargs) + else: + if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance( + output_stream, asyncio.StreamReader + ): + raise TypeError(_CLIENT_CONNECTION_ERROR) + self._conn = Connection(handler, input_stream, output_stream, **connection_kwargs) if on_connect := getattr(client, "on_connect", None): on_connect(self) diff --git a/src/acp/connection.py b/src/acp/connection.py index 41cdebc..cfd7b5c 100644 --- a/src/acp/connection.py +++ b/src/acp/connection.py @@ -8,10 +8,11 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from enum import Enum -from typing import Any +from typing import Any, cast from pydantic import BaseModel, ValidationError +from ._transport import NdjsonTransport, Transport from .exceptions import RequestError from .task import ( DefaultMessageDispatcher, @@ -63,8 +64,8 @@ class Connection: def __init__( self, handler: MethodHandler, - writer: asyncio.StreamWriter, - reader: asyncio.StreamReader, + writer: asyncio.StreamWriter | Transport, + reader: asyncio.StreamReader | None = None, *, queue: MessageQueue | None = None, state_store: MessageStateStore | None = None, @@ -75,8 +76,6 @@ def __init__( receive_timeout: float | None = None, ) -> None: self._handler = handler - self._writer = writer - self._reader = reader self._next_request_id = 0 self._state = state_store or InMemoryMessageStateStore() self._tasks = TaskSupervisor(source="acp.Connection") @@ -84,9 +83,18 @@ def __init__( self._queue = queue or InMemoryMessageQueue() self._closed = False self._disconnected = False - self._sender = (sender_factory or self._default_sender_factory)(self._writer, self._tasks) + # Two construction forms: + # * message-level: ``Connection(handler, transport)`` (reader omitted) + # * byte-level: ``Connection(handler, writer, reader)`` (stdio path) + # We discriminate on ``reader`` rather than ``isinstance(writer, Transport)`` + # because a runtime-checkable Protocol would spuriously match duck-typed + # test doubles (e.g. ``MagicMock``). + if reader is None: + self._transport: Transport = cast("Transport", writer) + else: + sender = (sender_factory or self._default_sender_factory)(cast("asyncio.StreamWriter", writer), self._tasks) + self._transport = NdjsonTransport(reader, sender, receive_timeout=receive_timeout) self._observers: list[StreamObserver] = list(observers or []) - self._receive_timeout = receive_timeout if listening: self._recv_task = self._tasks.create( self._receive_loop(), @@ -111,7 +119,7 @@ async def close(self) -> None: return self._closed = True await self._dispatcher.stop() - await self._sender.close() + await self._transport.close() await self._tasks.shutdown() self._state.reject_all_outgoing(ConnectionError("Connection closed")) @@ -139,30 +147,29 @@ async def send_request(self, method: str, params: JsonValue | None = None) -> An self._next_request_id += 1 future = self._state.register_outgoing(request_id, method) payload = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} - await self._sender.send(payload) + try: + await self._transport.send(payload) + except Exception as exc: + # A synchronous send failure (e.g. HTTP POST rejected before any + # JSON-RPC response exists) must reject the correlated future so the + # caller gets a real, attributable error. + self._state.reject_outgoing(request_id, exc) + raise self._notify_observers(StreamDirection.OUTGOING, payload) return await future async def send_notification(self, method: str, params: JsonValue | None = None) -> None: self._raise_if_unavailable() payload = {"jsonrpc": "2.0", "method": method, "params": params} - await self._sender.send(payload) + await self._transport.send(payload) self._notify_observers(StreamDirection.OUTGOING, payload) async def _receive_loop(self) -> None: try: while True: - line = await self._read_line() - if not line: + message = await self._transport.receive() + if message is None: break - line = line.strip() - if not line: - continue - try: - message: dict[str, Any] = json.loads(line) - except Exception: - logging.exception("Error parsing JSON-RPC message") - continue self._notify_observers(StreamDirection.INCOMING, message) await self._process_message(message) except asyncio.CancelledError: @@ -171,24 +178,6 @@ async def _receive_loop(self) -> None: raise RequestError.internal_error({"details": "Agent timeout"}) from None self._disconnect() - async def _read_line(self) -> bytes: - chunks: list[bytes] = [] - try: - while True: - try: - line = await self._wait_for_reader(self._reader.readuntil(b"\n")) - except asyncio.LimitOverrunError as exc: - chunks.append(await self._wait_for_reader(self._reader.readexactly(exc.consumed))) - else: - chunks.append(line) - return b"".join(chunks) - except asyncio.IncompleteReadError as exc: - chunks.append(exc.partial) - return b"".join(chunks) - - async def _wait_for_reader(self, awaitable: Awaitable[bytes]) -> bytes: - return await asyncio.wait_for(awaitable, timeout=self._receive_timeout) - async def _process_message(self, message: dict[str, Any]) -> None: method = message.get("method") has_id = "id" in message @@ -239,18 +228,18 @@ async def _run_request(self, message: dict[str, Any]) -> Any: exclude_unset=True, ) payload["result"] = result if result is not None else None - await self._sender.send(payload) + await self._transport.send(payload) self._notify_observers(StreamDirection.OUTGOING, payload) return payload.get("result") except RequestError as exc: payload["error"] = exc.to_error_obj() - await self._sender.send(payload) + await self._transport.send(payload) self._notify_observers(StreamDirection.OUTGOING, payload) raise except ValidationError as exc: err = RequestError.invalid_params({"errors": exc.errors()}) payload["error"] = err.to_error_obj() - await self._sender.send(payload) + await self._transport.send(payload) self._notify_observers(StreamDirection.OUTGOING, payload) raise err from None except Exception as exc: @@ -265,7 +254,7 @@ async def _run_request(self, message: dict[str, Any]) -> Any: data = {"details": str(exc)} err = RequestError.internal_error(data) payload["error"] = err.to_error_obj() - await self._sender.send(payload) + await self._transport.send(payload) self._notify_observers(StreamDirection.OUTGOING, payload) raise err from None diff --git a/src/acp/core.py b/src/acp/core.py index 2d280c6..d42b164 100644 --- a/src/acp/core.py +++ b/src/acp/core.py @@ -79,17 +79,26 @@ async def run_agent( def connect_to_agent( client: Client, input_stream: Any, - output_stream: Any, + output_stream: Any = None, *, use_unstable_protocol: bool = False, **connection_kwargs: Any, ) -> ClientSideConnection: - """Create a ClientSideConnection to an ACP agent over the given input/output streams. + """Create a ClientSideConnection to an ACP agent. + + Two forms are supported: + + * **Byte streams (stdio):** pass ``input_stream`` (an ``asyncio.StreamWriter``) + and ``output_stream`` (an ``asyncio.StreamReader``). + * **Message transport (HTTP/WebSocket):** pass a single + :class:`~acp._transport.Transport` as ``input_stream`` and leave + ``output_stream`` as ``None``. Args: client: The client implementation to use. - input_stream: The (agent) input stream to write to (default: ``sys.stdin``). - output_stream: The (agent) output stream to read from (default: ``sys.stdout``). + input_stream: The agent input stream (``StreamWriter``) or a ``Transport``. + output_stream: The agent output stream (``StreamReader``), or ``None`` when + passing a ``Transport``. use_unstable_protocol: Whether to enable unstable protocol features. **connection_kwargs: Additional keyword arguments to pass to the :class:`ClientSideConnection` constructor. diff --git a/src/acp/http/__init__.py b/src/acp/http/__init__.py new file mode 100644 index 0000000..7529047 --- /dev/null +++ b/src/acp/http/__init__.py @@ -0,0 +1,30 @@ +"""Streamable HTTP transport for ACP (experimental). + +Public exports are import-guarded: the heavy client/server implementations pull +in optional dependencies (``httpx[http2]``). Importing a symbol without the +extra installed raises a friendly ``ImportError`` pointing at +``pip install agent-client-protocol[http]``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +__all__ = ["AcpServer", "create_http_stream"] + +if TYPE_CHECKING: + from .client import create_http_stream + from .server import AcpServer + + +def __getattr__(name: str) -> Any: + if name == "create_http_stream": + from .client import create_http_stream + + return create_http_stream + if name == "AcpServer": + from .server import AcpServer + + return AcpServer + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) diff --git a/src/acp/http/asgi.py b/src/acp/http/asgi.py new file mode 100644 index 0000000..7dbd5f8 --- /dev/null +++ b/src/acp/http/asgi.py @@ -0,0 +1,175 @@ +"""Thin ASGI adapter bridging Starlette/FastAPI/Hypercorn to :class:`AcpServer`. + +``create_asgi_app(agent_factory)`` returns an ASGI 3.0 application callable that +handles POST/GET/DELETE (and WebSocket upgrades) on the ACP endpoint. Users can +mount it directly or wrap it in their framework of choice. + +Note: for a spec-compliant Streamable HTTP server, run this under an +HTTP/2-capable ASGI server (Hypercorn, Daphne, Granian) or terminate HTTP/2 at a +proxy. Uvicorn does not serve HTTP/2 (WebSocket still works). +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from .protocol import CONNECTION_ID_HEADER, CONTENT_TYPE_SSE, SESSION_ID_HEADER +from .server import AcpServer + +if TYPE_CHECKING: + from .server import AgentFactory + +__all__ = ["AcpAsgiApp", "create_asgi_app"] + +_JSON_HEADERS = [(b"content-type", b"application/json")] + + +def _header_lookup(scope_headers: list[tuple[bytes, bytes]], name: str) -> str | None: + target = name.lower().encode() + for key, value in scope_headers: + if key.lower() == target: + return value.decode("latin-1") + return None + + +class AcpAsgiApp: + """ASGI application wrapping an :class:`AcpServer`.""" + + def __init__(self, server: AcpServer) -> None: + self._server = server + + async def __call__(self, scope: dict[str, Any], receive: Callable, send: Callable) -> None: + scope_type = scope["type"] + if scope_type == "lifespan": + await self._handle_lifespan(receive, send) + return + if scope_type == "websocket": + await self._handle_websocket(scope, receive, send) + return + if scope_type != "http": + return + + method = scope["method"] + if method == "POST": + await self._handle_post(scope, receive, send) + elif method == "GET": + await self._handle_get(scope, receive, send) + elif method == "DELETE": + await self._handle_delete(scope, send) + else: + await self._send_json(send, 405, {"error": "Method not allowed"}) + + async def _handle_lifespan(self, receive: Callable, send: Callable) -> None: + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await self._server.close() + await send({"type": "lifespan.shutdown.complete"}) + return + + async def _read_body(self, receive: Callable) -> bytes: + chunks: list[bytes] = [] + while True: + message = await receive() + if message["type"] == "http.request": + chunks.append(message.get("body", b"")) + if not message.get("more_body", False): + break + elif message["type"] == "http.disconnect": + break + return b"".join(chunks) + + async def _handle_post(self, scope: dict[str, Any], receive: Callable, send: Callable) -> None: + headers = scope["headers"] + content_type = _header_lookup(headers, "content-type") + connection_id = _header_lookup(headers, CONNECTION_ID_HEADER) + session_id = _header_lookup(headers, SESSION_ID_HEADER) + raw = await self._read_body(receive) + try: + message = json.loads(raw) if raw else None + except json.JSONDecodeError: + await self._send_json(send, 400, {"error": "Invalid JSON"}) + return + result = await self._server.handle_post( + message, + content_type=content_type, + connection_id=connection_id, + session_id=session_id, + ) + await self._send_json(send, result.status, result.body, extra_headers=result.headers) + + async def _handle_get(self, scope: dict[str, Any], receive: Callable, send: Callable) -> None: + headers = scope["headers"] + upgrade = _header_lookup(headers, "upgrade") + if upgrade is not None and upgrade.lower() == "websocket": + # WebSocket upgrades arrive as scope type "websocket" in ASGI; a GET + # http scope with Upgrade is non-standard, so reject clearly. + await self._send_json(send, 400, {"error": "WebSocket upgrade must use the ws scope"}) + return + accept = _header_lookup(headers, "accept") or "" + if CONTENT_TYPE_SSE not in accept and "*/*" not in accept: + await self._send_json(send, 406, {"error": "Accept must include text/event-stream"}) + return + connection_id = _header_lookup(headers, CONNECTION_ID_HEADER) + session_id = _header_lookup(headers, SESSION_ID_HEADER) + error = self._server.validate_stream(connection_id=connection_id, session_id=session_id) + if error is not None: + await self._send_json(send, error.status, error.body) + return + if connection_id is None: # validated above, narrow for type-checker + await self._send_json(send, 400, {"error": "Missing connection id"}) + return + await send({ + "type": "http.response.start", + "status": 200, + "headers": [ + (b"content-type", CONTENT_TYPE_SSE.encode()), + (b"cache-control", b"no-cache"), + (b"connection", b"keep-alive"), + ], + }) + async for frame in self._server.open_stream(connection_id=connection_id, session_id=session_id): + await send({"type": "http.response.body", "body": frame, "more_body": True}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + + async def _handle_delete(self, scope: dict[str, Any], send: Callable) -> None: + connection_id = _header_lookup(scope["headers"], CONNECTION_ID_HEADER) + result = await self._server.handle_delete(connection_id=connection_id) + await self._send_json(send, result.status, result.body, extra_headers=result.headers) + + async def _handle_websocket(self, scope: dict[str, Any], receive: Callable, send: Callable) -> None: + from ..ws.server import handle_asgi_websocket + + await handle_asgi_websocket(self._server, scope, receive, send) + + async def _send_json( + self, + send: Callable, + status: int, + body: dict[str, Any] | None, + *, + extra_headers: dict[str, str] | None = None, + ) -> None: + payload = json.dumps(body).encode() if body is not None else b"" + headers = list(_JSON_HEADERS) + if extra_headers: + headers.extend((k.encode("latin-1"), v.encode("latin-1")) for k, v in extra_headers.items()) + await send({"type": "http.response.start", "status": status, "headers": headers}) + await send({"type": "http.response.body", "body": payload}) + + +def create_asgi_app(agent_factory: AgentFactory) -> AcpAsgiApp: + """Create an ASGI app serving an ACP agent over Streamable HTTP + WebSocket. + + Args: + agent_factory: Called once per connection with the bound + ``AgentSideConnection`` to produce a per-connection ``Agent``. + + Returns: + An :class:`AcpAsgiApp` ASGI 3.0 application. + """ + return AcpAsgiApp(AcpServer(agent_factory)) diff --git a/src/acp/http/client.py b/src/acp/http/client.py new file mode 100644 index 0000000..b224167 --- /dev/null +++ b/src/acp/http/client.py @@ -0,0 +1,238 @@ +"""Streamable HTTP client transport (port of #155 ``http-stream.ts``, minus retry). + +``create_http_stream(url, ...)`` returns a :class:`~acp._transport.Transport` +that can be handed to :func:`acp.connect_to_agent`. The flow: + +* The first message MUST be ``initialize``: POSTed as ``application/json``, + expecting ``200 OK`` + an ``Acp-Connection-Id`` response header. The JSON body + is enqueued back into ``receive()`` so the core correlates it by ``id``. +* Subsequent messages are POSTed with the connection id (+ session id header for + session-scoped methods) and return ``202 Accepted``. +* After ``initialize`` the client opens the connection-scoped SSE stream (GET); + when it sees a new ``sessionId`` it opens that session-scoped SSE stream too. +* Serverโ†’client messages arrive on those SSE streams, merged into one + ``receive()`` feed. Order is preserved within a stream, interleaved across. +* A single SSE attempt is made per stream; on EOF/closure the reader surfaces + end-of-stream (no auto-retry โ€” that is the caller's responsibility). +* ``close()`` aborts in-flight streams and DELETEs the connection. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from typing import TYPE_CHECKING, Any + +from .._sse import parse_sse_stream +from .protocol import ( + CONNECTION_ID_HEADER, + CONTENT_TYPE_JSON, + CONTENT_TYPE_SSE, + SESSION_ID_HEADER, + is_initialize_request, + method_requires_session_header, + session_id_from_message, +) + +try: + import httpx +except ImportError as exc: # pragma: no cover - exercised via import guard message + msg = "The Streamable HTTP transport requires the 'http' extra: pip install agent-client-protocol[http]" + raise ImportError(msg) from exc + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from .._transport import Transport + +__all__ = ["AcpHttpStatusError", "create_http_stream"] + +_EOF = object() + + +class AcpHttpStatusError(RuntimeError): + """Raised when an HTTP request returns an unexpected status code.""" + + def __init__(self, status_code: int, message: str) -> None: + super().__init__(f"HTTP {status_code}: {message}") + self.status_code = status_code + + +class _HttpStreamTransport: + """Streamable HTTP client transport implementing the :class:`Transport` protocol.""" + + def __init__( + self, + url: str, + *, + client: httpx.AsyncClient, + owns_client: bool, + headers: dict[str, str] | None = None, + ) -> None: + self._url = url + self._client = client + self._owns_client = owns_client + self._extra_headers = dict(headers or {}) + self._connection_id: str | None = None + self._closed = False + self._inbox: asyncio.Queue[Any] = asyncio.Queue() + self._stream_tasks: set[asyncio.Task[None]] = set() + self._session_streams: set[str] = set() + + # -- Transport protocol ------------------------------------------------- + + async def send(self, message: dict[str, Any]) -> None: + if self._closed: + raise ConnectionError("Transport closed") + if is_initialize_request(message): + await self._send_initialize(message) + return + await self._send_post(message) + + async def receive(self) -> dict[str, Any] | None: + item = await self._inbox.get() + if item is _EOF: + return None + return item + + async def close(self) -> None: + if self._closed: + return + self._closed = True + for task in list(self._stream_tasks): + task.cancel() + for task in list(self._stream_tasks): + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + self._stream_tasks.clear() + if self._connection_id is not None: + with contextlib.suppress(Exception): + await self._client.request( + "DELETE", + self._url, + headers={CONNECTION_ID_HEADER: self._connection_id, **self._extra_headers}, + ) + if self._owns_client: + with contextlib.suppress(Exception): + await self._client.aclose() + self._inbox.put_nowait(_EOF) + + # -- Internals ---------------------------------------------------------- + + async def _send_initialize(self, message: dict[str, Any]) -> None: + headers = {"Content-Type": CONTENT_TYPE_JSON, **self._extra_headers} + response = await self._client.post(self._url, json=message, headers=headers) + if response.status_code != 200: + raise AcpHttpStatusError(response.status_code, "initialize failed") + connection_id = response.headers.get(CONNECTION_ID_HEADER) + if not connection_id: + raise AcpHttpStatusError(response.status_code, f"missing {CONNECTION_ID_HEADER} header") + self._connection_id = connection_id + body = response.json() + # Enqueue the initialize result so the core correlates it by id. + self._inbox.put_nowait(body) + # Open the connection-scoped SSE stream. + self._open_stream(session_id=None) + + async def _send_post(self, message: dict[str, Any]) -> None: + if self._connection_id is None: + raise ConnectionError("Cannot send before initialize established a connection id") + headers = {"Content-Type": CONTENT_TYPE_JSON, CONNECTION_ID_HEADER: self._connection_id, **self._extra_headers} + method = message.get("method") + session_id = session_id_from_message(message) + if method_requires_session_header(method) and session_id is not None: + headers[SESSION_ID_HEADER] = session_id + response = await self._client.post(self._url, json=message, headers=headers) + if response.status_code not in (200, 202): + raise AcpHttpStatusError(response.status_code, f"POST {method} failed") + # Some servers may answer initialize-like 200 bodies; for 200 with a body enqueue it. + if response.status_code == 200 and response.content: + with contextlib.suppress(Exception): + self._inbox.put_nowait(response.json()) + + def _open_stream(self, *, session_id: str | None) -> None: + if self._closed: + return + if session_id is not None: + if session_id in self._session_streams: + return + self._session_streams.add(session_id) + task = asyncio.ensure_future(self._consume_stream(session_id=session_id)) + self._stream_tasks.add(task) + task.add_done_callback(self._stream_tasks.discard) + + async def _consume_stream(self, *, session_id: str | None) -> None: + if self._connection_id is None: + return + headers = { + "Accept": CONTENT_TYPE_SSE, + CONNECTION_ID_HEADER: self._connection_id, + **self._extra_headers, + } + if session_id is not None: + headers[SESSION_ID_HEADER] = session_id + try: + async with self._client.stream("GET", self._url, headers=headers) as response: + if response.status_code != 200: + return + async for event in parse_sse_stream(_aiter_raw(response)): + self._handle_incoming(event) + except (httpx.HTTPError, asyncio.CancelledError): + return + finally: + self._on_stream_closed(session_id) + + def _on_stream_closed(self, session_id: str | None) -> None: + """Handle a stream reader terminating (EOF, non-200, or error). + + A dropped/ended **connection-scoped** stream is the client's only channel + for connection-level serverโ†’client messages, so its loss is surfaced as + end-of-stream: ``receive()`` returns ``None``, the core's receive loop + exits, and pending requests are rejected instead of hanging forever. v1 + does not auto-reconnect โ€” that is the host's responsibility โ€” but the + disconnect must be observable. Session-scoped streams may legitimately + close, so their EOF is not treated as a connection-level disconnect. + """ + if session_id is not None: + self._session_streams.discard(session_id) + return + if not self._closed: + self._inbox.put_nowait(_EOF) + + def _handle_incoming(self, message: dict[str, Any]) -> None: + # Open a session-scoped stream when any message carries a new sessionId + # (e.g. a session/new or session/load result on the connection stream). + session_id = session_id_from_message(message) + if session_id is not None and session_id not in self._session_streams: + self._open_stream(session_id=session_id) + self._inbox.put_nowait(message) + + +async def _aiter_raw(response: httpx.Response) -> AsyncIterator[bytes]: + async for chunk in response.aiter_bytes(): + yield chunk + + +def create_http_stream( + url: str, + *, + client: httpx.AsyncClient | None = None, + headers: dict[str, str] | None = None, +) -> Transport: + """Create a Streamable HTTP client :class:`Transport`. + + Args: + url: The ACP endpoint URL (e.g. ``https://host/acp``). + client: An optional pre-configured ``httpx.AsyncClient``. If omitted, an + HTTP/2-enabled client with a cookie jar is created and owned by the + transport (closed on ``close()``). + headers: Extra headers sent on every request. + + Returns: + A :class:`Transport` usable with :func:`acp.connect_to_agent`. + """ + owns_client = client is None + if client is None: + # SSE GET streams are long-lived, so disable read timeouts by default. + client = httpx.AsyncClient(http2=True, timeout=httpx.Timeout(None)) + return _HttpStreamTransport(url, client=client, owns_client=owns_client, headers=headers) diff --git a/src/acp/http/protocol.py b/src/acp/http/protocol.py new file mode 100644 index 0000000..c15fdb4 --- /dev/null +++ b/src/acp/http/protocol.py @@ -0,0 +1,106 @@ +"""Protocol constants and JSON-RPC routing helpers for the HTTP/WS transport. + +Ports the small helpers from #155's ``protocol.ts`` + ``jsonrpc.ts``: header +names, MIME types, endpoint path, and pure functions that classify JSON-RPC +messages and extract routing keys (``id`` normalization, ``sessionId``). +""" + +from __future__ import annotations + +from typing import Any + +from ..meta import AGENT_METHODS + +__all__ = [ + "ACP_ENDPOINT_PATH", + "CONNECTION_ID_HEADER", + "CONTENT_TYPE_JSON", + "CONTENT_TYPE_SSE", + "INITIALIZE_METHOD", + "SESSION_ID_HEADER", + "is_initialize_request", + "is_response_message", + "message_id_key", + "method_requires_session_header", + "session_id_from_message", + "session_id_from_params", + "session_id_from_result", +] + +# Header names (case-insensitive on the wire; we normalize to these spellings). +CONNECTION_ID_HEADER = "Acp-Connection-Id" +SESSION_ID_HEADER = "Acp-Session-Id" + +# MIME types. +CONTENT_TYPE_JSON = "application/json" +CONTENT_TYPE_SSE = "text/event-stream" + +# Endpoint path used by docs/examples (the adapter itself is path-agnostic). +ACP_ENDPOINT_PATH = "/acp" + +INITIALIZE_METHOD = AGENT_METHODS["initialize"] + +# Agent methods that operate on an *already-established* session and therefore +# require the ``Acp-Session-Id`` header on POST + session-scoped routing of their +# response on GET. Methods that mint/attach a session id (``session/new``, +# ``session/load``, ``session/fork``, ``session/resume``) are deliberately +# excluded: per the RFD their responses come back on the connection-scoped stream +# because the client does not yet have the session-scoped stream open. +# ``session/list`` is connection-level. +_SESSION_SCOPED_METHODS = frozenset({ + AGENT_METHODS["session_set_mode"], + AGENT_METHODS["session_set_config_option"], + AGENT_METHODS["session_prompt"], + AGENT_METHODS["session_cancel"], + AGENT_METHODS["session_close"], +}) + + +def is_initialize_request(message: dict[str, Any]) -> bool: + """True if the message is an ``initialize`` JSON-RPC request.""" + return message.get("method") == INITIALIZE_METHOD and "id" in message + + +def is_response_message(message: dict[str, Any]) -> bool: + """True if the message is a JSON-RPC response (has ``id`` and no ``method``).""" + return "id" in message and "method" not in message + + +def method_requires_session_header(method: str | None) -> bool: + """True if a POST for ``method`` must carry the ``Acp-Session-Id`` header.""" + return method in _SESSION_SCOPED_METHODS + + +def message_id_key(message_id: Any) -> str | None: + """Normalize a JSON-RPC ``id`` (int or str) to a stable string key. + + The Python core assigns integer request ids while the wire may echo them as + ints or strings; routing tables must key consistently. Returns ``None`` for + a missing id. + """ + if message_id is None: + return None + return str(message_id) + + +def session_id_from_params(params: Any) -> str | None: + """Extract ``sessionId`` from a request's ``params`` object, if present.""" + if isinstance(params, dict): + session_id = params.get("sessionId") + if isinstance(session_id, str): + return session_id + return None + + +def session_id_from_result(result: Any) -> str | None: + """Extract ``sessionId`` from a response's ``result`` object, if present.""" + if isinstance(result, dict): + session_id = result.get("sessionId") + if isinstance(session_id, str): + return session_id + return None + + +def session_id_from_message(message: dict[str, Any]) -> str | None: + """Extract a ``sessionId`` from either a request's params or a response's result.""" + return session_id_from_params(message.get("params")) or session_id_from_result(message.get("result")) diff --git a/src/acp/http/server.py b/src/acp/http/server.py new file mode 100644 index 0000000..6c4f00f --- /dev/null +++ b/src/acp/http/server.py @@ -0,0 +1,461 @@ +"""Framework-agnostic Streamable HTTP server core (port of #155 server.ts + connection.ts). + +:class:`AcpServer` owns an in-memory :class:`ConnectionRegistry`. For each +``initialize`` POST it mints a connection, binds an ``AgentSideConnection`` to an +in-memory transport pair, and returns an ``Acp-Connection-Id``. Subsequent +serverโ†’client messages produced by the agent are fanned out to the correct SSE +stream (connection-scoped or session-scoped) based on their ``sessionId`` / +correlated request id. + +The core exposes small, transport-neutral entry points: + +* :meth:`AcpServer.handle_post` โ€” returns a :class:`PostResult` (status + body). +* :meth:`AcpServer.open_stream` โ€” returns an async byte iterator of SSE frames. +* :meth:`AcpServer.handle_delete` โ€” terminates a connection. + +The ASGI adapter in :mod:`acp.http.asgi` maps these onto ASGI messages. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import uuid +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from .._sse import serialize_sse_event, serialize_sse_keepalive +from .._transport import memory_transport_pair +from ..agent.connection import AgentSideConnection +from .protocol import ( + CONNECTION_ID_HEADER, + is_initialize_request, + is_response_message, + message_id_key, + method_requires_session_header, + session_id_from_params, + session_id_from_result, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from ..interfaces import Agent + +__all__ = [ + "AcpServer", + "AgentFactory", + "ConnectionRegistry", + "ConnectionState", + "OutboundStream", + "PostResult", +] + +AgentFactory = Callable[[AgentSideConnection], "Agent"] + +# How long an idle SSE stream waits before emitting a keepalive comment. Kept in +# sync with the TypeScript reference (15s) so intermediaries do not time out an +# otherwise-healthy but quiet stream. +SSE_KEEPALIVE_INTERVAL_SECONDS = 15.0 + +# How long ``initialize`` waits for the agent's response before giving up. The +# response is returned synchronously in the HTTP body, so a hung agent must not +# block the POST forever. +INITIALIZE_TIMEOUT_SECONDS = 30.0 + + +@dataclass +class PostResult: + """Outcome of a POST request.""" + + status: int + body: dict[str, Any] | None = None + headers: dict[str, str] = field(default_factory=dict) + + +class OutboundStream: + """A backpressure-aware buffer for serverโ†’client messages. + + Messages pushed before a subscriber attaches are buffered (bounded) and + replayed when :meth:`iterate` is first awaited. When the buffer is full, + :meth:`push` *awaits* until the consumer drains rather than dropping the + message โ€” dropping a JSON-RPC response would permanently hang the peer's + pending request. Awaiting propagates backpressure up to the agent's message + pump, mirroring the ``ReadableStream`` backpressure in the TypeScript SDK. + """ + + def __init__(self, *, capacity: int = 1024) -> None: + self._queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue(maxsize=capacity) + self._closed = asyncio.Event() + + async def push(self, message: dict[str, Any]) -> None: + if self._closed.is_set(): + return + putter = asyncio.ensure_future(self._queue.put(message)) + closed = asyncio.ensure_future(self._closed.wait()) + try: + await asyncio.wait({putter, closed}, return_when=asyncio.FIRST_COMPLETED) + finally: + # If the stream closed while we were blocked on a full queue, abandon + # the put; otherwise ensure the close-waiter task is cleaned up. + for task in (putter, closed): + if not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + + def close(self) -> None: + if self._closed.is_set(): + return + self._closed.set() + # Guarantee the consumer observes EOF even if the buffer is full: make + # room for the sentinel by dropping one buffered (tail) message, which is + # acceptable during teardown. + while not self._try_put_sentinel(): + with contextlib.suppress(asyncio.QueueEmpty): + self._queue.get_nowait() + + def _try_put_sentinel(self) -> bool: + try: + self._queue.put_nowait(None) + except asyncio.QueueFull: + return False + return True + + async def iterate(self) -> AsyncIterator[dict[str, Any]]: + while True: + message = await self._queue.get() + if message is None: + return + yield message + + +class ConnectionState: + """Owns an ``AgentSideConnection`` bound to an in-memory transport pair. + + The agent writes serverโ†’client messages onto the server end of the pair; a + pump task reads them and routes each to the connection-scoped stream or the + right session-scoped stream. + """ + + def __init__(self, connection_id: str, agent_factory: AgentFactory, *, multiplex: bool = False) -> None: + self.connection_id = connection_id + # ``server_side`` is what the AgentSideConnection talks over; ``pump_side`` + # is what we read agentโ†’client traffic from and inject clientโ†’agent on. + server_side, pump_side = memory_transport_pair() + self._pump_side = pump_side + self._agent_conn = AgentSideConnection(agent_factory, server_side, listening=True) + self.connection_stream = OutboundStream() + self.session_streams: dict[str, OutboundStream] = {} + # WebSocket mode: multiplex *all* agentโ†’client traffic onto one stream + # (the single socket) instead of splitting across SSE streams. + self._multiplex: OutboundStream | None = OutboundStream() if multiplex else None + # Maps a request id -> sessionId, so responses to session-scoped client + # requests route back onto the right session stream. + self._pending_routes: dict[str, str] = {} + # Request ids whose response should be captured (e.g. initialize) instead + # of being pushed to a stream. + self._response_waiters: dict[str, asyncio.Future[dict[str, Any]]] = {} + self._pump_task: asyncio.Task[None] | None = None + + def start(self) -> None: + self._pump_task = asyncio.ensure_future(self._pump()) + + async def _pump(self) -> None: + try: + while True: + message = await self._pump_side.receive() + if message is None: + return + await self._route_outbound(message) + except asyncio.CancelledError: + return + + async def _route_outbound(self, message: dict[str, Any]) -> None: + """Route an agentโ†’client message to the correct SSE stream. + + Rules (matching the RFD): + + * A response to a session-*establishing* request (``session/new`` / + ``session/load`` โ€” result carries a ``sessionId``) goes on the + **connection-scoped** stream, because the client does not yet have the + session-scoped stream open. We register the session so its stream can + be opened on the next GET. + * A response to an already-session-scoped client request routes onto that + session's stream (looked up via ``_pending_routes`` by request id). + * A serverโ†’client message carrying a ``sessionId`` in params (a + notification or request) routes onto that session's stream. + * Everything else goes on the connection-scoped stream. + """ + if is_response_message(message): + await self._route_response(message) + return + # Requests/notifications: route by sessionId in params if present. + if self._multiplex is not None: + await self._multiplex.push(message) + return + session_id = session_id_from_params(message.get("params")) + if session_id is not None and session_id in self.session_streams: + await self.session_streams[session_id].push(message) + return + await self.connection_stream.push(message) + + async def _route_response(self, message: dict[str, Any]) -> None: + key = message_id_key(message.get("id")) + # A captured response (e.g. initialize) resolves its waiter instead of + # being pushed to any stream. + if key is not None and key in self._response_waiters: + waiter = self._response_waiters.pop(key) + if not waiter.done(): + waiter.set_result(message) + return + # Register any newly-established session so unknown-session validation + # succeeds regardless of transport. + established = session_id_from_result(message.get("result")) + if established is not None: + self.ensure_session_stream(established) + routed = self._pending_routes.pop(key, None) if key is not None else None + if self._multiplex is not None: + await self._multiplex.push(message) + return + # session/new | session/load results (``established``) go on the + # connection-scoped stream; already-session-scoped responses route to the + # session stream recorded when the request came in. + if established is None and routed is not None and routed in self.session_streams: + await self.session_streams[routed].push(message) + return + await self.connection_stream.push(message) + + async def deliver_to_agent(self, message: dict[str, Any]) -> None: + """Inject a clientโ†’server message into the agent connection.""" + # Track session-scoped client requests so their responses route back. + if "id" in message and "method" in message: + session_id = session_id_from_params(message.get("params")) + if session_id is not None: + key = message_id_key(message["id"]) + if key is not None: + self._pending_routes[key] = session_id + await self._pump_side.send(message) + + async def request_response(self, message: dict[str, Any]) -> dict[str, Any]: + """Send a request to the agent and await its correlated response. + + Used for the ``initialize`` POST, which is the one request whose response + is returned synchronously in the HTTP body rather than over an SSE stream. + """ + key = message_id_key(message.get("id")) + loop = asyncio.get_running_loop() + future: asyncio.Future[dict[str, Any]] = loop.create_future() + if key is not None: + self._response_waiters[key] = future + await self.deliver_to_agent(message) + return await asyncio.wait_for(future, timeout=INITIALIZE_TIMEOUT_SECONDS) + + def ensure_session_stream(self, session_id: str) -> OutboundStream: + stream = self.session_streams.get(session_id) + if stream is None: + stream = OutboundStream() + self.session_streams[session_id] = stream + return stream + + def has_session(self, session_id: str) -> bool: + return session_id in self.session_streams + + async def iter_all_outbound(self) -> AsyncIterator[dict[str, Any]]: + """Iterate every agentโ†’client message (WebSocket multiplex mode).""" + if self._multiplex is None: + msg = "iter_all_outbound requires a multiplex connection (WebSocket)" + raise RuntimeError(msg) + async for message in self._multiplex.iterate(): + yield message + + async def close(self) -> None: + if self._pump_task is not None: + self._pump_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._pump_task + self.connection_stream.close() + for stream in self.session_streams.values(): + stream.close() + if self._multiplex is not None: + self._multiplex.close() + with contextlib.suppress(Exception): + await self._pump_side.close() + with contextlib.suppress(Exception): + await self._agent_conn.close() + + +class ConnectionRegistry: + """In-memory ``connectionId -> ConnectionState`` registry.""" + + def __init__(self) -> None: + self._connections: dict[str, ConnectionState] = {} + + def create(self, agent_factory: AgentFactory) -> ConnectionState: + connection_id = uuid.uuid4().hex + state = ConnectionState(connection_id, agent_factory) + state.start() + self._connections[connection_id] = state + return state + + def create_multiplex(self, agent_factory: AgentFactory) -> ConnectionState: + """Create a connection whose agentโ†’client traffic is multiplexed onto one + stream (used by the WebSocket transport).""" + connection_id = uuid.uuid4().hex + state = ConnectionState(connection_id, agent_factory, multiplex=True) + state.start() + self._connections[connection_id] = state + return state + + def get(self, connection_id: str) -> ConnectionState | None: + return self._connections.get(connection_id) + + async def remove(self, connection_id: str) -> None: + state = self._connections.pop(connection_id, None) + if state is not None: + await state.close() + + async def close_all(self) -> None: + for connection_id in list(self._connections): + await self.remove(connection_id) + + +class AcpServer: + """Framework-agnostic Streamable HTTP + WebSocket server core. + + Args: + agent_factory: Called once per connection with the bound + ``AgentSideConnection`` to produce a per-connection ``Agent``. + """ + + def __init__(self, agent_factory: AgentFactory) -> None: + self._agent_factory = agent_factory + self._registry = ConnectionRegistry() + + @property + def registry(self) -> ConnectionRegistry: + return self._registry + + def create_websocket_connection(self) -> ConnectionState: + """Create a new multiplexed connection for a WebSocket upgrade.""" + return self._registry.create_multiplex(self._agent_factory) + + # -- POST --------------------------------------------------------------- + + async def handle_post( + self, + message: Any, + *, + content_type: str | None, + connection_id: str | None, + session_id: str | None, + ) -> PostResult: + if content_type is None or not content_type.lower().startswith("application/json"): + return PostResult(415, {"error": "Content-Type must be application/json"}) + if isinstance(message, list): + return PostResult(501, {"error": "Batch requests are not supported"}) + if not isinstance(message, dict): + return PostResult(400, {"error": "Invalid JSON-RPC message"}) + + if is_initialize_request(message): + return await self._handle_initialize(message) + + if connection_id is None: + return PostResult(400, {"error": "Missing connection id"}) + state = self._registry.get(connection_id) + if state is None: + return PostResult(404, {"error": "Unknown connection id"}) + + method = message.get("method") + if method_requires_session_header(method) and session_id is None: + return PostResult(400, {"error": "Missing session id header"}) + if session_id is not None and not state.has_session(session_id): + # A session-scoped POST references an unknown session. + return PostResult(404, {"error": "Unknown session id"}) + + await state.deliver_to_agent(message) + return PostResult(202) + + async def _handle_initialize(self, message: dict[str, Any]) -> PostResult: + state = self._registry.create(self._agent_factory) + # Deliver initialize to the agent and await its response so we can return + # the 200 body synchronously (initialize is the one blocking POST). If the + # agent never responds (timeout) or errors, tear the just-created + # connection down instead of leaking its pump task + agent connection. + try: + response = await state.request_response(message) + except TimeoutError: + await self._registry.remove(state.connection_id) + return PostResult(504, {"error": "initialize timed out"}) + except Exception: + await self._registry.remove(state.connection_id) + return PostResult(500, {"error": "initialize failed"}) + return PostResult(200, response, {CONNECTION_ID_HEADER: state.connection_id}) + + # -- GET / SSE ---------------------------------------------------------- + + def validate_stream(self, *, connection_id: str | None, session_id: str | None) -> PostResult | None: + """Validate a GET SSE request. Returns an error PostResult, or None if OK.""" + if connection_id is None: + return PostResult(400, {"error": "Missing connection id"}) + state = self._registry.get(connection_id) + if state is None: + return PostResult(404, {"error": "Unknown connection id"}) + if session_id is not None and not state.has_session(session_id): + return PostResult(404, {"error": "Unknown session id"}) + return None + + async def open_stream( + self, + *, + connection_id: str, + session_id: str | None, + ) -> AsyncIterator[bytes]: + """Yield SSE byte frames for a connection- or session-scoped stream. + + Emits a keepalive comment whenever the stream is idle for longer than + :data:`SSE_KEEPALIVE_INTERVAL_SECONDS` so that idle-timeout intermediaries + (proxies, load balancers) do not close an otherwise-healthy stream. + """ + state = self._registry.get(connection_id) + if state is None: + return + stream = state.ensure_session_stream(session_id) if session_id is not None else state.connection_stream + messages = stream.iterate() + pending: asyncio.Task[dict[str, Any]] | None = None + try: + while True: + if pending is None: + pending = asyncio.ensure_future(messages.__anext__()) + done, _ = await asyncio.wait({pending}, timeout=SSE_KEEPALIVE_INTERVAL_SECONDS) + if not done: + # Idle: emit a keepalive and keep awaiting the same message. + yield serialize_sse_keepalive() + continue + try: + message = pending.result() + except StopAsyncIteration: + return + finally: + pending = None + yield serialize_sse_event(message) + finally: + if pending is not None: + pending.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await pending + await messages.aclose() + + # -- DELETE ------------------------------------------------------------- + + async def handle_delete(self, *, connection_id: str | None) -> PostResult: + if connection_id is None: + return PostResult(400, {"error": "Missing connection id"}) + if self._registry.get(connection_id) is None: + return PostResult(404, {"error": "Unknown connection id"}) + await self._registry.remove(connection_id) + return PostResult(202) + + async def close(self) -> None: + await self._registry.close_all() diff --git a/src/acp/ws/__init__.py b/src/acp/ws/__init__.py new file mode 100644 index 0000000..51ab2a9 --- /dev/null +++ b/src/acp/ws/__init__.py @@ -0,0 +1,23 @@ +"""WebSocket transport for ACP (experimental). + +Public exports are import-guarded behind the ``http`` extra (which provides the +``websockets`` dependency). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +__all__ = ["create_websocket_stream"] + +if TYPE_CHECKING: + from .client import create_websocket_stream + + +def __getattr__(name: str) -> Any: + if name == "create_websocket_stream": + from .client import create_websocket_stream + + return create_websocket_stream + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) diff --git a/src/acp/ws/client.py b/src/acp/ws/client.py new file mode 100644 index 0000000..18acf5b --- /dev/null +++ b/src/acp/ws/client.py @@ -0,0 +1,117 @@ +"""WebSocket client transport (port of #155 ``ws-stream.ts``). + +``create_websocket_stream(url, ...)`` connects a WebSocket and returns a +:class:`~acp._transport.Transport`. Messages are JSON-RPC **text** frames; +binary frames are ignored. The client must still send ``initialize`` as the +first message over the socket. +""" + +from __future__ import annotations + +import contextlib +import json +from typing import TYPE_CHECKING, Any + +from .._cookies import MemoryAcpCookieStore + +try: + import websockets + from websockets.asyncio.client import connect as ws_connect +except ImportError as exc: # pragma: no cover - exercised via import guard message + msg = "The WebSocket transport requires the 'http' extra: pip install agent-client-protocol[http]" + raise ImportError(msg) from exc + +if TYPE_CHECKING: + from .._transport import Transport + +__all__ = ["MemoryAcpCookieStore", "create_websocket_stream"] + +# Case-insensitive header name a server uses to set connection-affinity cookies +# on the WebSocket upgrade response. +_SET_COOKIE_HEADER = "Set-Cookie" + + +class _WebSocketTransport: + """WebSocket client transport implementing the :class:`Transport` protocol.""" + + def __init__(self, connection: Any) -> None: + self._ws = connection + self._closed = False + + async def send(self, message: dict[str, Any]) -> None: + if self._closed: + raise ConnectionError("Transport closed") + await self._ws.send(json.dumps(message, separators=(",", ":"))) + + async def receive(self) -> dict[str, Any] | None: + while True: + try: + frame = await self._ws.recv() + except websockets.ConnectionClosed: + return None + # Ignore binary frames; only text JSON-RPC is meaningful. + if isinstance(frame, bytes): + continue + try: + return json.loads(frame) + except json.JSONDecodeError: + continue + + async def close(self) -> None: + if self._closed: + return + self._closed = True + with contextlib.suppress(Exception): + await self._ws.close() + + +async def create_websocket_stream( + url: str, + *, + headers: dict[str, str] | None = None, + cookie_store: MemoryAcpCookieStore | None = None, +) -> Transport: + """Connect a WebSocket and return a :class:`Transport`. + + Per the RFD, clients MUST accept, store, and return cookies on all HTTP-based + transports (including WebSocket) so servers can rely on cookies for session + affinity (e.g. sticky sessions behind a load balancer). A single WebSocket is + one long-lived connection, so cookie support matters across *reconnects*: + pass a caller-owned ``cookie_store`` reused between fresh streams. Any cookies + already in the store are sent as a ``Cookie`` header on the handshake, and any + ``Set-Cookie`` headers on the upgrade response are captured back into it. + + Args: + url: The ACP WebSocket endpoint (e.g. ``ws://host/acp``). + headers: Extra headers sent during the handshake. + cookie_store: Optional caller-owned affinity cookie store to reuse across + reconnects. If omitted, an ephemeral per-stream store is used. + + Returns: + A connected :class:`Transport` usable with :func:`acp.connect_to_agent`. + """ + store = cookie_store if cookie_store is not None else MemoryAcpCookieStore() + request_headers = dict(headers or {}) + cookie_header = store.cookie_header() + if cookie_header and not _has_header(request_headers, "Cookie"): + request_headers["Cookie"] = cookie_header + connection = await ws_connect(url, additional_headers=request_headers or None) + _capture_set_cookies(connection, store) + return _WebSocketTransport(connection) + + +def _has_header(headers: dict[str, str], name: str) -> bool: + lowered = name.lower() + return any(key.lower() == lowered for key in headers) + + +def _capture_set_cookies(connection: Any, store: MemoryAcpCookieStore) -> None: + """Store ``Set-Cookie`` headers from the WebSocket upgrade response.""" + response = getattr(connection, "response", None) + response_headers = getattr(response, "headers", None) + if response_headers is None: + return + get_all = getattr(response_headers, "get_all", None) + values = list(get_all(_SET_COOKIE_HEADER)) if get_all is not None else [] + if values: + store.store_set_cookies(values) diff --git a/src/acp/ws/server.py b/src/acp/ws/server.py new file mode 100644 index 0000000..e5ff43f --- /dev/null +++ b/src/acp/ws/server.py @@ -0,0 +1,79 @@ +"""WebSocket server handling for the ASGI adapter (port of #155 ws-server.ts). + +On upgrade we create a fresh :class:`~acp.http.server.ConnectionState` (bound to +its own ``AgentSideConnection``), accept the socket with an ``Acp-Connection-Id`` +header, then pump JSON-RPC text frames both directions. All serverโ†’client +traffic (across the connection- and every session-scoped stream) is multiplexed +onto the single socket. On disconnect the connection and its sessions are torn +down. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from ..http.protocol import CONNECTION_ID_HEADER + +if TYPE_CHECKING: + from ..http.server import AcpServer, ConnectionState + +__all__ = ["handle_asgi_websocket"] + + +async def handle_asgi_websocket( + server: AcpServer, + scope: dict[str, Any], + receive: Callable, + send: Callable, +) -> None: + """Handle an ASGI ``websocket`` scope by bridging it to a new ACP connection.""" + # Wait for the connect message. + message = await receive() + if message["type"] != "websocket.connect": + return + + state = server.create_websocket_connection() + await send({ + "type": "websocket.accept", + "headers": [(CONNECTION_ID_HEADER.lower().encode(), state.connection_id.encode())], + }) + + outbound_task = asyncio.ensure_future(_pump_outbound(state, send)) + try: + await _pump_inbound(state, receive) + finally: + outbound_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await outbound_task + await server.registry.remove(state.connection_id) + + +async def _pump_inbound(state: ConnectionState, receive: Callable) -> None: + """Read clientโ†’server text frames and deliver them to the agent.""" + while True: + message = await receive() + msg_type = message["type"] + if msg_type == "websocket.disconnect": + return + if msg_type != "websocket.receive": + continue + text = message.get("text") + if text is None: + # Ignore binary frames. + continue + try: + payload = json.loads(text) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + await state.deliver_to_agent(payload) + + +async def _pump_outbound(state: ConnectionState, send: Callable) -> None: + """Forward all agentโ†’client messages onto the socket as text frames.""" + async for message in state.iter_all_outbound(): + await send({"type": "websocket.send", "text": json.dumps(message, separators=(",", ":"))}) diff --git a/tests/http/conftest.py b/tests/http/conftest.py new file mode 100644 index 0000000..f96355c --- /dev/null +++ b/tests/http/conftest.py @@ -0,0 +1,62 @@ +"""Shared fixtures for HTTP/WS loopback tests: run an ASGI app under uvicorn.""" + +from __future__ import annotations + +import asyncio +import contextlib +import socket +from collections.abc import AsyncIterator, Callable +from typing import Any + +import pytest_asyncio +import uvicorn + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class RunningServer: + def __init__(self, host: str, port: int) -> None: + self.host = host + self.port = port + + @property + def http_url(self) -> str: + return f"http://{self.host}:{self.port}/acp" + + @property + def ws_url(self) -> str: + return f"ws://{self.host}:{self.port}/acp" + + +@pytest_asyncio.fixture +async def serve_asgi() -> AsyncIterator[Callable[[Any], Any]]: + """Yield a factory that boots an ASGI app under uvicorn and returns a RunningServer.""" + servers: list[uvicorn.Server] = [] + tasks: list[asyncio.Task[Any]] = [] + + async def _start(app: Any) -> RunningServer: + host, port = "127.0.0.1", _free_port() + config = uvicorn.Config(app, host=host, port=port, log_level="warning", lifespan="on") + server = uvicorn.Server(config) + servers.append(server) + task = asyncio.ensure_future(server.serve()) + tasks.append(task) + # Wait until the server is up. + for _ in range(100): + if server.started: + break + await asyncio.sleep(0.02) + return RunningServer(host, port) + + try: + yield _start + finally: + for server in servers: + server.should_exit = True + for task in tasks: + with contextlib.suppress(asyncio.CancelledError, Exception): + await asyncio.wait_for(task, timeout=5) diff --git a/tests/http/test_cookies.py b/tests/http/test_cookies.py new file mode 100644 index 0000000..5bc0ae4 --- /dev/null +++ b/tests/http/test_cookies.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from acp._cookies import MemoryAcpCookieStore + + +def test_store_and_render_single_cookie() -> None: + store = MemoryAcpCookieStore() + store.store_set_cookie("affinity=abc123; Path=/; HttpOnly") + assert store.cookie_header() == "affinity=abc123" + + +def test_store_multiple_cookies_preserves_all() -> None: + store = MemoryAcpCookieStore() + store.store_set_cookies(["a=1; Path=/", "b=2; Secure"]) + assert store.cookie_header() == "a=1; b=2" + + +def test_later_value_overwrites_same_name() -> None: + store = MemoryAcpCookieStore() + store.store_set_cookie("a=1") + store.store_set_cookie("a=2") + assert store.cookie_header() == "a=2" + assert len(store) == 1 + + +def test_empty_store_returns_none() -> None: + store = MemoryAcpCookieStore() + assert store.cookie_header() is None + + +def test_malformed_set_cookie_ignored() -> None: + store = MemoryAcpCookieStore() + store.store_set_cookie("garbage") + store.store_set_cookie("") + assert store.cookie_header() is None + + +def test_clear_drops_all() -> None: + store = MemoryAcpCookieStore() + store.store_set_cookie("a=1") + store.clear() + assert store.cookie_header() is None diff --git a/tests/http/test_fixes.py b/tests/http/test_fixes.py new file mode 100644 index 0000000..2a8c307 --- /dev/null +++ b/tests/http/test_fixes.py @@ -0,0 +1,186 @@ +"""Regression tests for reliability fixes on the HTTP/WS transport. + +Covers: +* OutboundStream backpressure (no silent message drops under a full buffer). +* SSE keepalive emission on idle streams. +* HTTP client surfacing disconnect (EOF) when the connection-scoped SSE stream ends. +* Server cleanup of a leaked connection when ``initialize`` fails/times out. +* WebSocket client cookie support (send stored Cookie; capture Set-Cookie). +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import httpx +import pytest + +import acp.http.server as server_mod +from acp.http.client import create_http_stream +from acp.http.protocol import CONNECTION_ID_HEADER, CONTENT_TYPE_JSON +from acp.http.server import AcpServer, OutboundStream + +CT_JSON = "application/json" + + +# -- Finding 1: OutboundStream backpressure ------------------------------------ + + +@pytest.mark.asyncio +async def test_outbound_stream_does_not_drop_beyond_capacity() -> None: + """Pushing more than ``capacity`` messages must not silently drop any. + + With a bounded queue and ``put_nowait``, the (capacity+1)-th message was + dropped. Backpressure-aware push blocks the producer until a consumer drains, + so every message is eventually delivered in order. + """ + stream = OutboundStream(capacity=2) + total = 5 + + async def produce() -> None: + for i in range(total): + await stream.push({"n": i}) + stream.close() + + producer = asyncio.ensure_future(produce()) + received = [msg async for msg in stream.iterate()] + await producer + assert received == [{"n": i} for i in range(total)] + + +@pytest.mark.asyncio +async def test_outbound_stream_push_blocks_when_full() -> None: + """push must not complete once the buffer is full and no consumer drains.""" + stream = OutboundStream(capacity=1) + await stream.push({"n": 0}) # fills the buffer + blocked = asyncio.ensure_future(stream.push({"n": 1})) + await asyncio.sleep(0.05) + assert not blocked.done() + # Draining one message unblocks the producer. + it = stream.iterate() + assert await it.__anext__() == {"n": 0} + await asyncio.wait_for(blocked, timeout=1) + stream.close() + + +# -- Finding 3: SSE keepalive -------------------------------------------------- + + +@pytest.mark.asyncio +async def test_open_stream_emits_keepalive_when_idle(monkeypatch: pytest.MonkeyPatch) -> None: + """An idle connection-scoped stream must emit periodic SSE keepalive frames.""" + monkeypatch.setattr(server_mod, "SSE_KEEPALIVE_INTERVAL_SECONDS", 0.05) + + server = AcpServer(lambda conn: _NoopAgent()) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": 1}}, + content_type=CT_JSON, + connection_id=None, + session_id=None, + ) + connection_id = result.headers[CONNECTION_ID_HEADER] + + frames: list[bytes] = [] + + async def drain() -> None: + async for frame in server.open_stream(connection_id=connection_id, session_id=None): + frames.append(frame) + + task = asyncio.ensure_future(drain()) + await asyncio.sleep(0.2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await server.close() + + assert any(frame == b": keepalive\n\n" for frame in frames) + + +# -- Finding 5: initialize failure cleanup ------------------------------------- + + +@pytest.mark.asyncio +async def test_initialize_timeout_cleans_up_connection(monkeypatch: pytest.MonkeyPatch) -> None: + """A hung ``initialize`` must not leak a registered connection.""" + monkeypatch.setattr(server_mod, "INITIALIZE_TIMEOUT_SECONDS", 0.1) + + server = AcpServer(lambda conn: _SilentInitAgent()) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": 1}}, + content_type=CT_JSON, + connection_id=None, + session_id=None, + ) + assert result.status >= 500 + # No connection should remain registered after a failed initialize. + assert server.registry.get(result.headers.get(CONNECTION_ID_HEADER, "")) is None + assert _registry_size(server) == 0 + await server.close() + + +# -- Finding 4: HTTP client surfaces disconnect on stream EOF ------------------ + + +@pytest.mark.asyncio +async def test_http_client_surfaces_eof_when_connection_stream_ends() -> None: + """When the connection-scoped SSE stream ends, receive() must return None.""" + conn_id = "conn-eof" + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + body = json.loads(request.content) + if body.get("method") == "initialize": + return httpx.Response( + 200, + headers={CONNECTION_ID_HEADER: conn_id, "Content-Type": CONTENT_TYPE_JSON}, + json={"jsonrpc": "2.0", "id": body["id"], "result": {}}, + ) + return httpx.Response(202) + if request.method == "GET": + # SSE stream that immediately ends (empty body -> EOF). + return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=b"") + return httpx.Response(202) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + transport = create_http_stream("http://testserver/acp", client=client) + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + # Drain the initialize result. + assert await asyncio.wait_for(transport.receive(), timeout=1) == {"jsonrpc": "2.0", "id": 0, "result": {}} + # The connection stream ends -> transport must surface EOF, not hang. + assert await asyncio.wait_for(transport.receive(), timeout=1) is None + finally: + await transport.close() + await client.aclose() + + +# -- Helpers ------------------------------------------------------------------- + + +def _registry_size(server: AcpServer) -> int: + return len(server.registry._connections) # type: ignore[attr-defined] + + +class _NoopAgent: + def __init__(self) -> None: + self._conn: Any = None + + def on_connect(self, conn: Any) -> None: + self._conn = conn + + async def initialize(self, protocol_version: int = 1, **kwargs: Any) -> Any: + from acp.schema import InitializeResponse + + return InitializeResponse(protocol_version=1) + + +class _SilentInitAgent: + """An agent whose initialize never returns, forcing a server-side timeout.""" + + def on_connect(self, conn: Any) -> None: + pass + + async def initialize(self, protocol_version: int = 1, **kwargs: Any) -> Any: + await asyncio.sleep(3600) diff --git a/tests/http/test_http_client.py b/tests/http/test_http_client.py new file mode 100644 index 0000000..cacb296 --- /dev/null +++ b/tests/http/test_http_client.py @@ -0,0 +1,213 @@ +"""Unit tests for the Streamable HTTP client transport (ported from http-stream.test.ts).""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import httpx +import pytest + +from acp._sse import serialize_sse_event +from acp.http.client import AcpHttpStatusError, create_http_stream +from acp.http.protocol import CONNECTION_ID_HEADER, CONTENT_TYPE_JSON, SESSION_ID_HEADER + +CONN_ID = "conn-123" + + +class FakeServer: + """A minimal in-memory Streamable HTTP server backed by httpx.MockTransport.""" + + def __init__(self) -> None: + self.posts: list[dict[str, Any]] = [] + self.deleted = False + # Queues feeding the connection-scoped and session-scoped SSE streams. + self.conn_stream: asyncio.Queue[bytes | None] = asyncio.Queue() + self.session_streams: dict[str, asyncio.Queue[bytes | None]] = {} + + def handler(self, request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return self._handle_post(request) + if request.method == "GET": + return self._handle_get(request) + if request.method == "DELETE": + self.deleted = True + return httpx.Response(202) + return httpx.Response(405) + + def _handle_post(self, request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + self.posts.append(body) + if body.get("method") == "initialize": + return httpx.Response( + 200, + headers={CONNECTION_ID_HEADER: CONN_ID, "Content-Type": CONTENT_TYPE_JSON}, + json={"jsonrpc": "2.0", "id": body["id"], "result": {"protocolVersion": 1}}, + ) + return httpx.Response(202) + + def _handle_get(self, request: httpx.Request) -> httpx.Response: + session_id = request.headers.get(SESSION_ID_HEADER) + if session_id is not None: + queue = self.session_streams.setdefault(session_id, asyncio.Queue()) + else: + queue = self.conn_stream + + async def body() -> Any: + while True: + chunk = await queue.get() + if chunk is None: + return + yield chunk + + return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, stream=_AsyncByteStream(body())) + + def push_conn(self, message: dict[str, Any]) -> None: + self.conn_stream.put_nowait(serialize_sse_event(message)) + + def push_session(self, session_id: str, message: dict[str, Any]) -> None: + queue = self.session_streams.setdefault(session_id, asyncio.Queue()) + queue.put_nowait(serialize_sse_event(message)) + + +class _AsyncByteStream(httpx.AsyncByteStream): + def __init__(self, iterator: Any) -> None: + self._iterator = iterator + + async def __aiter__(self) -> Any: + async for chunk in self._iterator: + yield chunk + + +def _make_transport(server: FakeServer): + client = httpx.AsyncClient(transport=httpx.MockTransport(server.handler)) + return create_http_stream("http://testserver/acp", client=client), client + + +@pytest.mark.asyncio +async def test_initialize_posts_and_reads_connection_id() -> None: + server = FakeServer() + transport, client = _make_transport(server) + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + # The initialize result is enqueued back for the core to correlate. + result = await asyncio.wait_for(transport.receive(), timeout=1) + assert result == {"jsonrpc": "2.0", "id": 0, "result": {"protocolVersion": 1}} + assert server.posts[0]["method"] == "initialize" + finally: + await transport.close() + await client.aclose() + + +@pytest.mark.asyncio +async def test_initialize_failure_raises() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + transport = create_http_stream("http://testserver/acp", client=client) + try: + with pytest.raises(AcpHttpStatusError) as exc: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + assert exc.value.status_code == 500 # type: ignore[attr-defined] + finally: + await transport.close() + await client.aclose() + + +@pytest.mark.asyncio +async def test_connection_scoped_sse_delivers_new_session_result() -> None: + server = FakeServer() + transport, client = _make_transport(server) + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + await asyncio.wait_for(transport.receive(), timeout=1) # drain initialize result + # session/new POST returns 202; the result comes over the connection stream. + await transport.send({"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {}}) + await asyncio.sleep(0.05) + server.push_conn({"jsonrpc": "2.0", "id": 1, "result": {"sessionId": "sess-1"}}) + msg = await asyncio.wait_for(transport.receive(), timeout=1) + assert msg == {"jsonrpc": "2.0", "id": 1, "result": {"sessionId": "sess-1"}} + assert any(p.get("method") == "session/new" for p in server.posts) + finally: + await transport.close() + await client.aclose() + + +@pytest.mark.asyncio +async def test_session_scoped_sse_opens_after_new_session() -> None: + server = FakeServer() + transport, client = _make_transport(server) + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + await asyncio.wait_for(transport.receive(), timeout=1) + await transport.send({"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {}}) + await asyncio.sleep(0.05) + server.push_conn({"jsonrpc": "2.0", "id": 1, "result": {"sessionId": "sess-1"}}) + await asyncio.wait_for(transport.receive(), timeout=1) # session/new result + # Give the client time to open the session-scoped stream. + await asyncio.sleep(0.05) + assert "sess-1" in server.session_streams + # A session-scoped notification arrives on the merged feed. + server.push_session("sess-1", {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "sess-1"}}) + msg = await asyncio.wait_for(transport.receive(), timeout=1) + assert msg["method"] == "session/update" + finally: + await transport.close() + await client.aclose() + + +@pytest.mark.asyncio +async def test_session_scoped_post_sends_session_header() -> None: + server = FakeServer() + transport, client = _make_transport(server) + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + await asyncio.wait_for(transport.receive(), timeout=1) + await transport.send({"jsonrpc": "2.0", "id": 2, "method": "session/prompt", "params": {"sessionId": "sess-1"}}) + prompt_post = next(p for p in server.posts if p.get("method") == "session/prompt") + assert prompt_post["params"]["sessionId"] == "sess-1" + finally: + await transport.close() + await client.aclose() + + +@pytest.mark.asyncio +async def test_close_deletes_connection() -> None: + server = FakeServer() + transport, client = _make_transport(server) + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + await asyncio.wait_for(transport.receive(), timeout=1) + await transport.close() + assert server.deleted is True + # After close, receive() yields EOF. + assert await asyncio.wait_for(transport.receive(), timeout=1) is None + await client.aclose() + + +@pytest.mark.asyncio +async def test_post_error_status_raises() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + body = json.loads(request.content) + if body.get("method") == "initialize": + return httpx.Response( + 200, + headers={CONNECTION_ID_HEADER: CONN_ID}, + json={"jsonrpc": "2.0", "id": body["id"], "result": {}}, + ) + return httpx.Response(404) + return httpx.Response(200, headers={"Content-Type": "text/event-stream"}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + transport = create_http_stream("http://testserver/acp", client=client) + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + await asyncio.wait_for(transport.receive(), timeout=1) + with pytest.raises(AcpHttpStatusError) as exc: + await transport.send({"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {}}) + assert exc.value.status_code == 404 # type: ignore[attr-defined] + finally: + await transport.close() + await client.aclose() diff --git a/tests/http/test_http_server.py b/tests/http/test_http_server.py new file mode 100644 index 0000000..92f9290 --- /dev/null +++ b/tests/http/test_http_server.py @@ -0,0 +1,233 @@ +"""Tests for the framework-agnostic AcpServer core (ported from server*.test.ts).""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from acp.http.protocol import CONNECTION_ID_HEADER +from acp.http.server import AcpServer +from acp.schema import NewSessionResponse, PromptResponse +from tests.conftest import TestAgent + +CT_JSON = "application/json" + + +class _Agent(TestAgent): + """A test agent that streams a notification during prompt and can ask permission.""" + + def __init__(self) -> None: + super().__init__() + self._conn: Any = None + self.ask_permission = False + + def on_connect(self, conn: Any) -> None: + self._conn = conn + + async def new_session(self, cwd: str | None = None, mcp_servers: Any = None, **kwargs: Any) -> NewSessionResponse: + return NewSessionResponse(session_id="sess-1") + + async def prompt(self, session_id: str, prompt: Any = None, **kwargs: Any) -> PromptResponse: + # Emit a session-scoped notification back to the client. + await self._conn.session_update( + session_id=session_id, + update={"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hi"}}, + ) + if self.ask_permission: + await self._conn.request_permission( + session_id=session_id, + tool_call={"toolCallId": "t1", "title": "run"}, + options=[{"optionId": "allow", "name": "Allow", "kind": "allow_once"}], + ) + return PromptResponse(stop_reason="end_turn") + + +def _agent_factory(agent: _Agent): + return lambda conn: agent + + +async def _drain_stream(server: AcpServer, connection_id: str, session_id: str | None, out: list[bytes]) -> None: + async for frame in server.open_stream(connection_id=connection_id, session_id=session_id): + out.append(frame) + + +@pytest.mark.asyncio +async def test_post_wrong_content_type_returns_415() -> None: + server = AcpServer(_agent_factory(_Agent())) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}, + content_type="text/plain", + connection_id=None, + session_id=None, + ) + assert result.status == 415 + await server.close() + + +@pytest.mark.asyncio +async def test_batch_returns_501() -> None: + server = AcpServer(_agent_factory(_Agent())) + result = await server.handle_post([], content_type=CT_JSON, connection_id=None, session_id=None) + assert result.status == 501 + await server.close() + + +@pytest.mark.asyncio +async def test_initialize_creates_connection_and_returns_id() -> None: + server = AcpServer(_agent_factory(_Agent())) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": 1, "clientCapabilities": {}}}, + content_type=CT_JSON, + connection_id=None, + session_id=None, + ) + assert result.status == 200 + assert CONNECTION_ID_HEADER in result.headers + assert result.body is not None + assert result.body["id"] == 0 + await server.close() + + +@pytest.mark.asyncio +async def test_missing_connection_id_returns_400() -> None: + server = AcpServer(_agent_factory(_Agent())) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {}}, + content_type=CT_JSON, + connection_id=None, + session_id=None, + ) + assert result.status == 400 + await server.close() + + +@pytest.mark.asyncio +async def test_unknown_connection_id_returns_404() -> None: + server = AcpServer(_agent_factory(_Agent())) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {}}, + content_type=CT_JSON, + connection_id="nope", + session_id=None, + ) + assert result.status == 404 + await server.close() + + +async def _initialize(server: AcpServer) -> str: + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": 1, "clientCapabilities": {}}}, + content_type=CT_JSON, + connection_id=None, + session_id=None, + ) + return result.headers[CONNECTION_ID_HEADER] + + +@pytest.mark.asyncio +async def test_session_new_result_on_connection_stream() -> None: + server = AcpServer(_agent_factory(_Agent())) + conn_id = await _initialize(server) + frames: list[bytes] = [] + task = asyncio.ensure_future(_drain_stream(server, conn_id, None, frames)) + await asyncio.sleep(0.05) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {"cwd": ".", "mcpServers": []}}, + content_type=CT_JSON, + connection_id=conn_id, + session_id=None, + ) + assert result.status == 202 + await asyncio.sleep(0.1) + joined = b"".join(frames).decode() + assert '"sessionId":"sess-1"' in joined + assert '"id":1' in joined + task.cancel() + await server.close() + + +@pytest.mark.asyncio +async def test_session_scoped_missing_session_header_returns_400() -> None: + server = AcpServer(_agent_factory(_Agent())) + conn_id = await _initialize(server) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 2, "method": "session/prompt", "params": {"sessionId": "sess-1"}}, + content_type=CT_JSON, + connection_id=conn_id, + session_id=None, + ) + assert result.status == 400 + await server.close() + + +@pytest.mark.asyncio +async def test_prompt_streams_notification_on_session_stream() -> None: + server = AcpServer(_agent_factory(_Agent())) + conn_id = await _initialize(server) + # Create the session first. + await server.handle_post( + {"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {"cwd": ".", "mcpServers": []}}, + content_type=CT_JSON, + connection_id=conn_id, + session_id=None, + ) + await asyncio.sleep(0.05) + session_frames: list[bytes] = [] + conn_frames: list[bytes] = [] + st = asyncio.ensure_future(_drain_stream(server, conn_id, "sess-1", session_frames)) + ct = asyncio.ensure_future(_drain_stream(server, conn_id, None, conn_frames)) + await asyncio.sleep(0.05) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 2, "method": "session/prompt", "params": {"sessionId": "sess-1", "prompt": []}}, + content_type=CT_JSON, + connection_id=conn_id, + session_id="sess-1", + ) + assert result.status == 202 + await asyncio.sleep(0.15) + session_joined = b"".join(session_frames).decode() + # The agent_message_chunk notification is session-scoped. + assert "agent_message_chunk" in session_joined + # The prompt response (id 2) also routes to the session stream. + assert '"id":2' in session_joined + st.cancel() + ct.cancel() + await server.close() + + +@pytest.mark.asyncio +async def test_delete_terminates_connection() -> None: + server = AcpServer(_agent_factory(_Agent())) + conn_id = await _initialize(server) + result = await server.handle_delete(connection_id=conn_id) + assert result.status == 202 + # Subsequent use of the connection id 404s. + follow = await server.handle_post( + {"jsonrpc": "2.0", "id": 5, "method": "session/new", "params": {}}, + content_type=CT_JSON, + connection_id=conn_id, + session_id=None, + ) + assert follow.status == 404 + await server.close() + + +@pytest.mark.asyncio +async def test_delete_missing_connection_id_returns_400() -> None: + server = AcpServer(_agent_factory(_Agent())) + result = await server.handle_delete(connection_id=None) + assert result.status == 400 + await server.close() + + +@pytest.mark.asyncio +async def test_get_validation_errors() -> None: + server = AcpServer(_agent_factory(_Agent())) + assert server.validate_stream(connection_id=None, session_id=None).status == 400 # type: ignore[union-attr] + assert server.validate_stream(connection_id="nope", session_id=None).status == 404 # type: ignore[union-attr] + conn_id = await _initialize(server) + assert server.validate_stream(connection_id=conn_id, session_id="ghost").status == 404 # type: ignore[union-attr] + assert server.validate_stream(connection_id=conn_id, session_id=None) is None + await server.close() diff --git a/tests/http/test_loopback.py b/tests/http/test_loopback.py new file mode 100644 index 0000000..07b863d --- /dev/null +++ b/tests/http/test_loopback.py @@ -0,0 +1,131 @@ +"""End-to-end in-process loopback tests: Python client transport <-> ASGI server. + +Boots the ASGI app under a real uvicorn server (httpx's ASGITransport buffers +whole responses and cannot consume infinite SSE streams), then drives the full +ACP flow over both the Streamable HTTP and WebSocket transports. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from acp import connect_to_agent +from acp.http.asgi import create_asgi_app +from acp.http.client import create_http_stream +from acp.schema import InitializeResponse, NewSessionResponse, PromptResponse, RequestPermissionResponse +from acp.ws.client import create_websocket_stream +from tests.conftest import TestAgent, TestClient + + +class _LoopbackAgent(TestAgent): + def __init__(self) -> None: + super().__init__() + self._conn: Any = None + self.ask_permission = False + + def on_connect(self, conn: Any) -> None: + self._conn = conn + + async def initialize(self, protocol_version: int = 1, **kwargs: Any) -> InitializeResponse: + return InitializeResponse(protocol_version=1) + + async def new_session(self, cwd: str | None = None, mcp_servers: Any = None, **kwargs: Any) -> NewSessionResponse: + return NewSessionResponse(session_id="sess-loop") + + async def prompt(self, session_id: str, prompt: Any = None, **kwargs: Any) -> PromptResponse: + await self._conn.session_update( + session_id=session_id, + update={"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hello"}}, + ) + if self.ask_permission: + await self._conn.request_permission( + session_id=session_id, + tool_call={"toolCallId": "t1", "title": "run"}, + options=[{"optionId": "allow", "name": "Allow", "kind": "allow_once"}], + ) + return PromptResponse(stop_reason="end_turn") + + +def _make_app(agent: _LoopbackAgent) -> Any: + return create_asgi_app(lambda conn: agent) + + +class _CapturingClient(TestClient): + def __init__(self) -> None: + super().__init__() + self.updates: list[Any] = [] + self.permission_requested = False + + async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None: + self.updates.append(update) + + async def request_permission(self, session_id: str, tool_call: Any, options: Any, **kwargs: Any): + self.permission_requested = True + return RequestPermissionResponse.model_validate({"outcome": {"outcome": "selected", "optionId": "allow"}}) + + +# -- Streamable HTTP ----------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_http_loopback_initialize_and_new_session(serve_asgi) -> None: + agent = _LoopbackAgent() + server = await serve_asgi(_make_app(agent)) + transport = create_http_stream(server.http_url) + conn = connect_to_agent(_CapturingClient(), transport) + try: + init = await asyncio.wait_for(conn.initialize(protocol_version=1), timeout=10) + assert init.protocol_version == 1 + new = await asyncio.wait_for(conn.new_session(cwd=".", mcp_servers=[]), timeout=10) + assert new.session_id == "sess-loop" + finally: + await conn.close() + await transport.close() + + +@pytest.mark.asyncio +async def test_http_loopback_prompt_streams_and_permission(serve_asgi) -> None: + agent = _LoopbackAgent() + agent.ask_permission = True + server = await serve_asgi(_make_app(agent)) + transport = create_http_stream(server.http_url) + client = _CapturingClient() + conn = connect_to_agent(client, transport) + try: + await asyncio.wait_for(conn.initialize(protocol_version=1), timeout=10) + new = await asyncio.wait_for(conn.new_session(cwd=".", mcp_servers=[]), timeout=10) + result = await asyncio.wait_for(conn.prompt(session_id=new.session_id, prompt=[]), timeout=10) + assert result.stop_reason == "end_turn" + await asyncio.sleep(0.2) + assert client.updates, "expected a session/update notification over SSE" + assert client.permission_requested, "expected a server->client permission request" + finally: + await conn.close() + await transport.close() + + +# -- WebSocket ----------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_ws_loopback_prompt_streams_and_permission(serve_asgi) -> None: + agent = _LoopbackAgent() + agent.ask_permission = True + server = await serve_asgi(_make_app(agent)) + transport = await create_websocket_stream(server.ws_url) + client = _CapturingClient() + conn = connect_to_agent(client, transport) + try: + await asyncio.wait_for(conn.initialize(protocol_version=1), timeout=10) + new = await asyncio.wait_for(conn.new_session(cwd=".", mcp_servers=[]), timeout=10) + result = await asyncio.wait_for(conn.prompt(session_id=new.session_id, prompt=[]), timeout=10) + assert result.stop_reason == "end_turn" + await asyncio.sleep(0.2) + assert client.updates, "expected a session/update notification over WS" + assert client.permission_requested, "expected a server->client permission request" + finally: + await conn.close() + await transport.close() diff --git a/tests/http/test_protocol.py b/tests/http/test_protocol.py new file mode 100644 index 0000000..8b58cba --- /dev/null +++ b/tests/http/test_protocol.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from acp.http.protocol import ( + INITIALIZE_METHOD, + is_initialize_request, + is_response_message, + message_id_key, + method_requires_session_header, + session_id_from_message, + session_id_from_params, + session_id_from_result, +) + + +def test_is_initialize_request() -> None: + assert is_initialize_request({"method": INITIALIZE_METHOD, "id": 1, "params": {}}) + assert not is_initialize_request({"method": "session/new", "id": 2}) + # A notification (no id) is not a request. + assert not is_initialize_request({"method": INITIALIZE_METHOD}) + + +def test_is_response_message() -> None: + assert is_response_message({"id": 1, "result": {}}) + assert is_response_message({"id": 1, "error": {"code": -1, "message": "x"}}) + assert not is_response_message({"id": 1, "method": "session/prompt"}) + assert not is_response_message({"method": "session/update", "params": {}}) + + +def test_method_requires_session_header() -> None: + assert method_requires_session_header("session/prompt") + assert method_requires_session_header("session/cancel") + assert method_requires_session_header("session/set_mode") + # Connection-level methods (and session-establishing ones) do not require the header. + assert not method_requires_session_header("initialize") + assert not method_requires_session_header("session/new") + assert not method_requires_session_header("session/load") + assert not method_requires_session_header("session/list") + assert not method_requires_session_header(None) + + +def test_message_id_key_normalizes_int_and_str() -> None: + assert message_id_key(1) == "1" + assert message_id_key("1") == "1" + assert message_id_key(1) == message_id_key("1") + assert message_id_key(None) is None + + +def test_session_id_extraction() -> None: + assert session_id_from_params({"sessionId": "s1"}) == "s1" + assert session_id_from_params({}) is None + assert session_id_from_params(None) is None + assert session_id_from_result({"sessionId": "s2"}) == "s2" + assert session_id_from_message({"params": {"sessionId": "s3"}}) == "s3" + assert session_id_from_message({"result": {"sessionId": "s4"}}) == "s4" + assert session_id_from_message({"result": {}}) is None diff --git a/tests/http/test_sse.py b/tests/http/test_sse.py new file mode 100644 index 0000000..2003c1b --- /dev/null +++ b/tests/http/test_sse.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest + +from acp._sse import parse_sse_stream, serialize_sse_event, serialize_sse_keepalive + + +def test_serialize_sse_event_frames_json_with_blank_line() -> None: + frame = serialize_sse_event({"jsonrpc": "2.0", "id": 1, "result": {"ok": True}}) + assert frame == b'data: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n' + + +def test_serialize_sse_keepalive_is_a_comment() -> None: + assert serialize_sse_keepalive() == b": keepalive\n\n" + + +async def _aiter(chunks: list[bytes]) -> AsyncIterator[bytes]: + for chunk in chunks: + yield chunk + + +@pytest.mark.asyncio +async def test_parse_single_event() -> None: + stream = _aiter([b'data: {"id":1}\n\n']) + events = [event async for event in parse_sse_stream(stream)] + assert events == [{"id": 1}] + + +@pytest.mark.asyncio +async def test_parse_multiple_events_split_across_chunks() -> None: + stream = _aiter([b'data: {"id', b'":1}\n\ndata: {"id":2}', b"\n\n"]) + events = [event async for event in parse_sse_stream(stream)] + assert events == [{"id": 1}, {"id": 2}] + + +@pytest.mark.asyncio +async def test_parse_ignores_comments_and_other_fields() -> None: + stream = _aiter([b': keepalive\n\nevent: message\ndata: {"id":7}\n\n']) + events = [event async for event in parse_sse_stream(stream)] + assert events == [{"id": 7}] + + +@pytest.mark.asyncio +async def test_parse_multiline_data_is_joined() -> None: + stream = _aiter([b'data: {"a":1,\ndata: "b":2}\n\n']) + events = [event async for event in parse_sse_stream(stream)] + assert events == [{"a": 1, "b": 2}] + + +@pytest.mark.asyncio +async def test_parse_flushes_trailing_event_without_blank_line() -> None: + stream = _aiter([b'data: {"id":9}\n']) + events = [event async for event in parse_sse_stream(stream)] + assert events == [{"id": 9}] + + +@pytest.mark.asyncio +async def test_parse_skips_invalid_json() -> None: + stream = _aiter([b'data: not-json\n\ndata: {"id":1}\n\n']) + events = [event async for event in parse_sse_stream(stream)] + assert events == [{"id": 1}] diff --git a/tests/http/test_websocket.py b/tests/http/test_websocket.py new file mode 100644 index 0000000..cf21cf5 --- /dev/null +++ b/tests/http/test_websocket.py @@ -0,0 +1,182 @@ +"""WebSocket transport tests (client + ASGI server handler).""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import pytest +from websockets.asyncio.server import serve + +from acp.http.protocol import CONNECTION_ID_HEADER +from acp.http.server import AcpServer +from acp.schema import NewSessionResponse, PromptResponse +from acp.ws.client import create_websocket_stream +from acp.ws.server import handle_asgi_websocket +from tests.conftest import TestAgent + + +class _Agent(TestAgent): + def __init__(self) -> None: + super().__init__() + self._conn: Any = None + + def on_connect(self, conn: Any) -> None: + self._conn = conn + + async def new_session(self, cwd: str | None = None, mcp_servers: Any = None, **kwargs: Any) -> NewSessionResponse: + return NewSessionResponse(session_id="sess-ws") + + async def prompt(self, session_id: str, prompt: Any = None, **kwargs: Any) -> PromptResponse: + await self._conn.session_update( + session_id=session_id, + update={"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "yo"}}, + ) + return PromptResponse(stop_reason="end_turn") + + +# -- Client transport against a plain echo websocket server -------------------- + + +@pytest.mark.asyncio +async def test_client_transport_send_receive_text_frames() -> None: + async def echo(ws: Any) -> None: + async for msg in ws: + data = json.loads(msg) + await ws.send(json.dumps({"echo": data})) + + async with serve(echo, "localhost", 0) as server: + port = server.sockets[0].getsockname()[1] + transport = await create_websocket_stream(f"ws://localhost:{port}") + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + received = await asyncio.wait_for(transport.receive(), timeout=1) + assert received == {"echo": {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}} + finally: + await transport.close() + + +@pytest.mark.asyncio +async def test_client_transport_receive_returns_none_on_close() -> None: + async def close_immediately(ws: Any) -> None: + await ws.close() + + async with serve(close_immediately, "localhost", 0) as server: + port = server.sockets[0].getsockname()[1] + transport = await create_websocket_stream(f"ws://localhost:{port}") + try: + assert await asyncio.wait_for(transport.receive(), timeout=1) is None + finally: + await transport.close() + + +@pytest.mark.asyncio +async def test_client_transport_ignores_binary_frames() -> None: + async def send_binary_then_text(ws: Any) -> None: + await ws.send(b"\x00\x01") + await ws.send(json.dumps({"ok": True})) + await ws.recv() + + async with serve(send_binary_then_text, "localhost", 0) as server: + port = server.sockets[0].getsockname()[1] + transport = await create_websocket_stream(f"ws://localhost:{port}") + try: + received = await asyncio.wait_for(transport.receive(), timeout=1) + assert received == {"ok": True} + finally: + await transport.close() + + +# -- ASGI websocket server handler --------------------------------------------- + + +class _FakeAsgiSocket: + """In-memory ASGI websocket double driving handle_asgi_websocket.""" + + def __init__(self) -> None: + self._incoming: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self.sent: list[dict[str, Any]] = [] + self.accepted_headers: list[tuple[bytes, bytes]] = [] + self._sent_event = asyncio.Event() + + def client_connect(self) -> None: + self._incoming.put_nowait({"type": "websocket.connect"}) + + def client_send_text(self, message: dict[str, Any]) -> None: + self._incoming.put_nowait({"type": "websocket.receive", "text": json.dumps(message)}) + + def client_disconnect(self) -> None: + self._incoming.put_nowait({"type": "websocket.disconnect", "code": 1000}) + + async def receive(self) -> dict[str, Any]: + return await self._incoming.get() + + async def send(self, message: dict[str, Any]) -> None: + if message["type"] == "websocket.accept": + self.accepted_headers = message.get("headers", []) + elif message["type"] == "websocket.send": + self.sent.append(json.loads(message["text"])) + self._sent_event.set() + + async def wait_for_send(self, predicate, timeout: float = 1.0) -> dict[str, Any]: + async def _poll() -> dict[str, Any]: + while True: + for item in self.sent: + if predicate(item): + return item + self._sent_event.clear() + await self._sent_event.wait() + + return await asyncio.wait_for(_poll(), timeout=timeout) + + +@pytest.mark.asyncio +async def test_asgi_websocket_handshake_returns_connection_id() -> None: + server = AcpServer(lambda conn: _Agent()) + socket = _FakeAsgiSocket() + socket.client_connect() + handler = asyncio.ensure_future(handle_asgi_websocket(server, {"type": "websocket"}, socket.receive, socket.send)) + await asyncio.sleep(0.05) + header_names = [k for k, _ in socket.accepted_headers] + assert CONNECTION_ID_HEADER.lower().encode() in header_names + socket.client_disconnect() + await asyncio.wait_for(handler, timeout=1) + await server.close() + + +@pytest.mark.asyncio +async def test_asgi_websocket_full_flow() -> None: + server = AcpServer(lambda conn: _Agent()) + socket = _FakeAsgiSocket() + socket.client_connect() + handler = asyncio.ensure_future(handle_asgi_websocket(server, {"type": "websocket"}, socket.receive, socket.send)) + await asyncio.sleep(0.05) + + socket.client_send_text({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": 1}}) + init_resp = await socket.wait_for_send(lambda m: m.get("id") == 0) + assert "result" in init_resp + + socket.client_send_text({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/new", + "params": {"cwd": "/", "mcpServers": []}, + }) + new_resp = await socket.wait_for_send(lambda m: m.get("id") == 1) + assert new_resp["result"]["sessionId"] == "sess-ws" + + socket.client_send_text({ + "jsonrpc": "2.0", + "id": 2, + "method": "session/prompt", + "params": {"sessionId": "sess-ws", "prompt": []}, + }) + notif = await socket.wait_for_send(lambda m: m.get("method") == "session/update") + assert notif["params"]["sessionId"] == "sess-ws" + prompt_resp = await socket.wait_for_send(lambda m: m.get("id") == 2) + assert prompt_resp["result"]["stopReason"] == "end_turn" + + socket.client_disconnect() + await asyncio.wait_for(handler, timeout=1) + await server.close() diff --git a/tests/http/test_ws_cookies.py b/tests/http/test_ws_cookies.py new file mode 100644 index 0000000..a5fdd89 --- /dev/null +++ b/tests/http/test_ws_cookies.py @@ -0,0 +1,55 @@ +"""WebSocket client cookie support (RFD ยง5: cookies MUST work on WS transport).""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest +from websockets.asyncio.server import serve + +from acp._cookies import MemoryAcpCookieStore +from acp.ws.client import create_websocket_stream + + +@pytest.mark.asyncio +async def test_ws_client_captures_set_cookie_from_handshake() -> None: + """A caller-owned cookie store must capture Set-Cookie from the upgrade response.""" + + def process_response(connection: Any, request: Any, response: Any) -> Any: + response.headers["Set-Cookie"] = "affinity=abc123; Path=/" + return response + + async def handler(ws: Any) -> None: + await ws.close() + + store = MemoryAcpCookieStore() + async with serve(handler, "localhost", 0, process_response=process_response) as server: + port = server.sockets[0].getsockname()[1] + transport = await create_websocket_stream(f"ws://localhost:{port}", cookie_store=store) + try: + assert store.cookie_header() == "affinity=abc123" + finally: + await transport.close() + + +@pytest.mark.asyncio +async def test_ws_client_sends_stored_cookie_on_handshake() -> None: + """Stored cookies must be echoed back as a Cookie header on the next handshake.""" + seen: dict[str, Any] = {} + + async def handler(ws: Any) -> None: + seen["cookie"] = ws.request.headers.get("Cookie") + await ws.close() + + store = MemoryAcpCookieStore() + store.store_set_cookie("affinity=abc123") + + async with serve(handler, "localhost", 0) as server: + port = server.sockets[0].getsockname()[1] + transport = await create_websocket_stream(f"ws://localhost:{port}", cookie_store=store) + try: + await asyncio.sleep(0.05) + assert seen.get("cookie") == "affinity=abc123" + finally: + await transport.close() diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 4bdc0a9..5e8a917 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -744,7 +744,7 @@ async def test_connection_init_under_eager_task_factory(server): # Regression: under asyncio.eager_task_factory the receive loop runs synchronously # up to its first await inside Connection.__init__, so every attribute it reads - # (e.g. _receive_timeout) must be assigned before _tasks.create(_receive_loop()). + # (i.e. the message transport) must be assigned before _tasks.create(_receive_loop()). loop = asyncio.get_running_loop() previous_factory = loop.get_task_factory() loop.set_task_factory(eager_task_factory) @@ -758,7 +758,7 @@ async def test_connection_init_under_eager_task_factory(server): finally: loop.set_task_factory(previous_factory) - assert conn._receive_timeout == 0.5 + assert conn._transport._receive_timeout == 0.5 # type: ignore[attr-defined] # Let the loop tick once so any deferred receive-task crash would land. await asyncio.sleep(0) assert conn._disconnected is False diff --git a/uv.lock b/uv.lock index d8abdd3..a319fad 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,10 @@ dependencies = [ ] [package.optional-dependencies] +http = [ + { name = "httpx", extra = ["http2"] }, + { name = "websockets" }, +] logfire = [ { name = "logfire" }, { name = "opentelemetry-sdk" }, @@ -20,6 +24,7 @@ logfire = [ dev = [ { name = "datamodel-code-generator" }, { name = "deptry" }, + { name = "httpx", extra = ["http2"] }, { name = "mkdocs" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, @@ -30,20 +35,25 @@ dev = [ { name = "ruff" }, { name = "tox-uv" }, { name = "ty" }, + { name = "uvicorn" }, + { name = "websockets" }, ] [package.metadata] requires-dist = [ + { name = "httpx", extras = ["http2"], marker = "extra == 'http'", specifier = ">=0.27" }, { name = "logfire", marker = "extra == 'logfire'", specifier = ">=0.14" }, { name = "opentelemetry-sdk", marker = "extra == 'logfire'", specifier = ">=1.28.0" }, { name = "pydantic", specifier = ">=2.7" }, + { name = "websockets", marker = "extra == 'http'", specifier = ">=12.0" }, ] -provides-extras = ["logfire"] +provides-extras = ["logfire", "http"] [package.metadata.requires-dev] dev = [ { name = "datamodel-code-generator", specifier = ">=0.25" }, { name = "deptry", specifier = ">=0.23.0" }, + { name = "httpx", extras = ["http2"], specifier = ">=0.27" }, { name = "mkdocs", specifier = ">=1.4.2" }, { name = "mkdocs-material", specifier = ">=8.5.10" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.26.1" }, @@ -54,6 +64,8 @@ dev = [ { name = "ruff", specifier = ">=0.11.5" }, { name = "tox-uv", specifier = ">=1.11.3" }, { name = "ty", specifier = ">=0.0.1a16" }, + { name = "uvicorn", specifier = ">=0.30" }, + { name = "websockets", specifier = ">=12.0" }, ] [[package]] @@ -65,6 +77,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + [[package]] name = "argcomplete" version = "3.6.3" @@ -352,7 +378,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -422,6 +448,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.15" @@ -1536,6 +1635,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/7d/fa3a9960c95af9bbe2a629048760d0b9b4fead8ccd4f2235af747ec7cdf0/uv-0.11.15-py3-none-win_arm64.whl", hash = "sha256:4f39426a13dee24897aed60c4b98058c66f18bd983885ac5f4a54a04b24fbddf", size = 23198178, upload-time = "2026-05-18T19:57:14.68Z" }, ] +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + [[package]] name = "virtualenv" version = "20.36.1" @@ -1583,6 +1696,122 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] +[[package]] +name = "websockets" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/31/cd11d2796b95c93645bac8e396b0f4bac0896a07a7b87d473bfc359f02c3/websockets-16.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de72a9c611178b15557d98eabd3101c9663c4d68938510478a6d162f99afd213", size = 179772, upload-time = "2026-07-10T06:30:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/34306d802f9b599eab041688a2086318037560cfae616a860234cca575b6/websockets-16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:37b0e4d726ffea3776670092d3d13e1cb605076f036a695fd1259de0d9b9fe02", size = 177457, upload-time = "2026-07-10T06:30:24.636Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/36ebbb978a7af70ff952afe5b22561264967164e9ad68b6734cae94efeb4/websockets-16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:00d50c0a27098fcb7ab47b3d99a1b1159b534dbcd959fbf05113ebc37e5f927b", size = 177737, upload-time = "2026-07-10T06:30:25.954Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/944f341d0d3c0450ffd3d171479531df1818cb1df1623af4065113999c44/websockets-16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1acb698bff1da1782b31aebd8d7a24d7d05453964abcd7d03dbf6e25893908e8", size = 186244, upload-time = "2026-07-10T06:30:27.235Z" }, + { url = "https://files.pythonhosted.org/packages/32/e5/a9b98fc49ef0214718a9c839c6c63856a921877256ec46f371be32decfa8/websockets-16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc2c453f3b5f99c56b16e233aad5299860558487d26adb2ed27a00c14ca24b8c", size = 187484, upload-time = "2026-07-10T06:30:28.615Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7a/a575b52ca090b1976ffbe4b5f0762d03f399dfcb48eab883101331be71a9/websockets-16.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1a9f08a0728b0835f1c6abe1d9b746ab3de49b7336a0e1919cf96be1e76273eb", size = 190143, upload-time = "2026-07-10T06:30:29.91Z" }, + { url = "https://files.pythonhosted.org/packages/7c/40/705fbbd5677242fd36f724e9a94103e6bbdcb7d71e8f4498bfc1a8a7d413/websockets-16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a089979d6173b27af18026c8d8b0077f83669a9169174482c4651e9f5739a5b6", size = 188004, upload-time = "2026-07-10T06:30:31.357Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0c/58227c8d66b1c4060c53bac8e066fb4fe2603060408e934f48660a448d72/websockets-16.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3c18dba232ec2b92a68579c9fed8ff5a18f853d1e09fc0b6ca3159e94f689fe", size = 186689, upload-time = "2026-07-10T06:30:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d0/5c1314782594aa347e0f18808ee277a61986a2a2f9f470df9893183995bd/websockets-16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c1eb7df4170d5068892a8834fb5c07b9552353deb0dbeb0bff3820481ae4792", size = 184559, upload-time = "2026-07-10T06:30:34.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e6/109c6f16850fd674b7e3d0e58b8987f05d3881abaa25f42a9faf5e85f097/websockets-16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c522bd48e625b6d557aa228967258d6d3da031c4cc21d3352fb302479aa9ba0a", size = 186997, upload-time = "2026-07-10T06:30:35.397Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ec/6afa1aebc59426438b85cf7a3868c53a89005e2250a648c99e99943b90a4/websockets-16.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d106396927a7f00b0f3a69215c3357f87bf0bca6844247121f7e8291e826a3b1", size = 185621, upload-time = "2026-07-10T06:30:36.88Z" }, + { url = "https://files.pythonhosted.org/packages/27/24/c038fe8682e9345bfa422d2cc5cc68b0491ab942c92e176bf8dfa6e8331f/websockets-16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d71bed12909b8039955536e192867d02d76cd3797cedfd0facf822e7668636c3", size = 187384, upload-time = "2026-07-10T06:30:38.096Z" }, + { url = "https://files.pythonhosted.org/packages/30/ca/dc0ef2be39c67394e24bc982a0af59cd6249bf2f4e4272813c5c505d0da9/websockets-16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9c1cf6f9a936b030b5bed0e800c5ee32069338129084546baf5ff5014dc62fa9", size = 185258, upload-time = "2026-07-10T06:30:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/3ffecd83ca3404b41fbdf8e9b178e55b529cd59bf64ea08b5a37b616b568/websockets-16.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3fd3e6a7af2c8fcdcf4ffbeaf7f54a567b91a83267204187797f31faaa2a4efa", size = 186050, upload-time = "2026-07-10T06:30:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/75/26/2e068497c78f31591a610ab7ef6d8d383ecadbe98f9121e1ebda77ef6d2b/websockets-16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dddd27175bf640acae5561fa79b77e8ec71fc445816200523e5c19b6a556fb72", size = 186273, upload-time = "2026-07-10T06:30:42.309Z" }, + { url = "https://files.pythonhosted.org/packages/44/ab/4dc049cb2c9e1be3a2c6fef77118f9c5049979e99cd56a97759d2f40f980/websockets-16.1-cp310-cp310-win32.whl", hash = "sha256:cce36c80b3f2fede7942f1756d3d885fa6fa086766c8c1bcf00695ab80f0d51a", size = 180157, upload-time = "2026-07-10T06:30:43.565Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/5e010ce5f66a8e5df380843f704ada508195a021c0c8a0f933639c9ee1c0/websockets-16.1-cp310-cp310-win_amd64.whl", hash = "sha256:115fc4695b94bb855995b23fb1abcb66099a5995575d3d5bc5605a616c58d0eb", size = 180458, upload-time = "2026-07-10T06:30:45.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d47429afcc2c28616c32640009c84ea3f95660dab805766345b9682468e0/websockets-16.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a9b1d7a63cba8e6b9b77e499a81eab29d31100298d090ad4507d1048c0b9cae0", size = 179770, upload-time = "2026-07-10T06:30:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/2f0a722039a1e0107be73ed672ba604449b4956e48733e8e6b8a005aea42/websockets-16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bedbc5efeb96621aa2921d2d92608246691399418cac22acba427eb11877ea1f", size = 177455, upload-time = "2026-07-10T06:30:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/43/6a/c26b0ae449e93d256ce5cdd50d5fe97b575a63e8dcd311a1faa972fd6bc6/websockets-16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd847ab82133015afe65d778e7966ab42dba16bd7ad2e5b8a7918db6539f3f94", size = 177731, upload-time = "2026-07-10T06:30:49.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/3f/381550b344a02f0d2f84cda25e79b54575291bc7022128a41163fe8ba5b0/websockets-16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2fb33ccb16ee40a95cc676d7b0ff451a9a2632f11a0dbc2e666326892b2e1de", size = 187066, upload-time = "2026-07-10T06:30:50.505Z" }, + { url = "https://files.pythonhosted.org/packages/4a/87/5ab1ec2086910f23cfb9ec0c1c29fbcc24a9d190b5198b1557c00ce4a47e/websockets-16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f15b6d9ea9c2eaf6ccab964a082b09bfa6634a495bb0c2e9e7ee6943f58976", size = 188301, upload-time = "2026-07-10T06:30:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/bbbb8e6fac4cfc53d7aaa69a3d531bf10799354b0021f4b58914aced8c1a/websockets-16.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:638cf57c48b4ad8ac1ff1e453f4f97db2426b690ddc111e6da96b27b4a340bc3", size = 191594, upload-time = "2026-07-10T06:30:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/5c/da/6c0c349443d6e999f481e3d9a0e57e7ac2956d75d6391bec24b92af3fe13/websockets-16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c1c85f61bc9d5eac57ce705d848dc2d2ce3680638300bf4e1da7d749e2cf4ce", size = 188862, upload-time = "2026-07-10T06:30:54.744Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ea/a368d37c010425a5451f42052fe804e754e23333e8448aef5d55c8a8d64f/websockets-16.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:eeab6d27f51c7e579023c971f5e6dff200deadf01faf6831beaecd32052dfaef", size = 187633, upload-time = "2026-07-10T06:30:56.055Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4e/2ecd59add10d0855ec03dbdedfcdacdbd1aaabcd44b7dcbeda27538662e9/websockets-16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ed64e5a97b0b97a0b66e18bfe281317a75fbbd5afe692f939ea8d14a4292f2c", size = 185089, upload-time = "2026-07-10T06:30:57.444Z" }, + { url = "https://files.pythonhosted.org/packages/6f/eb/c6c3dcd7a01097bb0d42f4e9ef21a2c2a491d36b77cd0870ab59f9e8e77f/websockets-16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b3b021d0ed4bc16eea9775f62c9fa71acdacba0fc790b38581754dedf29ca60", size = 187790, upload-time = "2026-07-10T06:30:58.731Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3e/775d36885d5e48ab8020aaf377de0ff5fbeb8bc2682a7e46419e4a14521c/websockets-16.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6eb604a4167f0a0d53c2243dfc667a29f0b43c3436057184e070bb82a1000fa2", size = 186381, upload-time = "2026-07-10T06:31:00.355Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/6305c00812a92e47d0582604c02bd759db0118bbafc13f707d712dbcf898/websockets-16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9a3f125e44c3e34d61d111652e608e0f5b85ce08c225c8d56ad0eb822fa40030", size = 188193, upload-time = "2026-07-10T06:31:01.677Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/96bf8302c81d961585b4d34a2ddd3f229782f9b8c57bc78bbf98f1b1a4ac/websockets-16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8fdf0b00d0d1f30d1f06a92cab46fe542eec3eb302a7aee7163f142d0780f216", size = 185771, upload-time = "2026-07-10T06:31:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1f/e8fe44b1d2dc417d740d9959d28fd2a846f268e7df38a686c04ac7dfe947/websockets-16.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67b56828712f5fa7852de4c0265c28827311a657a4d275b7312ed0d1a918bee4", size = 186803, upload-time = "2026-07-10T06:31:04.34Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/b07d3a4e1eb2ab03e94e7f53f0c7a628e85fde6ad86011f7afd08f27b985/websockets-16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39c7e7730be33b8f0cd6f0aa8e8c82f9cdd1813f159765e073b2ece65f4824b5", size = 187041, upload-time = "2026-07-10T06:31:05.567Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fd/e0abb8acc435642ac4a671490f6cf781c882f3fe682cdced9080ea455ab5/websockets-16.1-cp311-cp311-win32.whl", hash = "sha256:c54fe94fb2f11e11b48920c5f971e298cec73ac35db56efe57a49db63dfc95d4", size = 180158, upload-time = "2026-07-10T06:31:06.929Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/85574d9458d3b913090087b817df0cc47b68e9a01dd0ab6ac04b77f49b0a/websockets-16.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9f4fb9ae8b802e55609685db98382d48fd3feb1397804e1e774968dea0f28c7", size = 180456, upload-time = "2026-07-10T06:31:08.247Z" }, + { url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" }, + { url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" }, + { url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" }, + { url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" }, + { url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" }, + { url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" }, + { url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" }, + { url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" }, + { url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" }, + { url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" }, + { url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" }, + { url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" }, + { url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f4/84ef884775bbe77c46cce79bc7d705ea3bc6574cc00acf81af89754c077d/websockets-16.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7289d899c79e763e6221c8dcb8959361cb43274418538d7c7ad16a43b01d12f9", size = 177387, upload-time = "2026-07-10T06:32:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d9/6831ec6f65e1eeac770375f4f4b604f23df9bafaa1b47004bc5f9488d513/websockets-16.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e22e9e3719f5131bd62da4db63c8da63eb8c91cc99e16c1cbd122f130e1ae07a", size = 177663, upload-time = "2026-07-10T06:32:50.043Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/21d4922fa7fe855813a8b38f181a0ecf02a586e16c1f095fd05471f78cc2/websockets-16.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83bdabafef431247e6b11a9aab8a0893fd8e82e1ed95b32e0373625b03ffce4a", size = 178501, upload-time = "2026-07-10T06:32:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/91/87/7a0320df854dacd09507ca972cb04a4dc5aae279583cc5b80ad5f5819533/websockets-16.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b8d13ceabc5c60995f201b5211d76876e17e68706ebf5d3bc666b32eefff1a6", size = 179397, upload-time = "2026-07-10T06:32:52.892Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/0da1eb8c8da2ace7b578c8523d32618af85e62a9ebad56051d4a14a38a1c/websockets-16.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81495f9c0085361c582efbc3207fb877174cfe03370f17d9cd70624404aa526f", size = 180546, upload-time = "2026-07-10T06:32:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, +] + [[package]] name = "wrapt" version = "1.17.3" From 34477bf520ef4358d11ab383c7ac21b15b17512f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:47:09 +0800 Subject: [PATCH 3/4] chore(deps-dev): bump datamodel-code-generator (#123) Bumps the uv group with 1 update in the / directory: [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator). Updates `datamodel-code-generator` from 0.35.0 to 0.64.0 - [Release notes](https://github.com/koxudaxi/datamodel-code-generator/releases) - [Changelog](https://github.com/koxudaxi/datamodel-code-generator/blob/main/CHANGELOG.md) - [Commits](https://github.com/koxudaxi/datamodel-code-generator/compare/0.35.0...0.64.0) --- updated-dependencies: - dependency-name: datamodel-code-generator dependency-version: 0.64.0 dependency-type: direct:development dependency-group: uv ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/uv.lock b/uv.lock index a319fad..5c62ff3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.15" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "agent-client-protocol" @@ -315,23 +319,22 @@ wheels = [ [[package]] name = "datamodel-code-generator" -version = "0.35.0" +version = "0.64.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, - { name = "black" }, + { name = "black", marker = "sys_platform != 'emscripten'" }, { name = "genson" }, { name = "inflect" }, - { name = "isort" }, + { name = "isort", marker = "sys_platform != 'emscripten'" }, { name = "jinja2" }, - { name = "packaging" }, { name = "pydantic" }, { name = "pyyaml" }, - { name = "tomli", marker = "python_full_version < '3.12'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/e1/dbf7c2edb1b1db1f4fd472ee92f985ec97d58902512013d9c4584108329c/datamodel_code_generator-0.35.0.tar.gz", hash = "sha256:46805fa2515d3871f6bfafce9aa63128e735a7a6a4cfcbf9c27b3794ee4ea846", size = 459915, upload-time = "2025-10-09T19:26:49.837Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/d2/86c94a2836ed42231653a7ddaefa0a5bc23418167a876bba7376c96b3a35/datamodel_code_generator-0.64.0.tar.gz", hash = "sha256:9c592900a00b20e416494273c22435f5a9aef6ea8c7b9190747522a60497a1cb", size = 1316440, upload-time = "2026-06-14T17:24:50.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/ef/0ed17459fe6076219fcd45f69a0bb4bd1cb041b39095ca2946808a9b5f04/datamodel_code_generator-0.35.0-py3-none-any.whl", hash = "sha256:c356d1e4a555f86667a4262db03d4598a30caeda8f51786555fd269c8abb806b", size = 121436, upload-time = "2025-10-09T19:26:48.437Z" }, + { url = "https://files.pythonhosted.org/packages/23/94/71338e2f0146ac10747a5537b3a1e45256e66b7c229869eb0ee787111b41/datamodel_code_generator-0.64.0-py3-none-any.whl", hash = "sha256:b7cd8bd41a312aa997aec6150670bad781847c5b674f17e4d70e78208a0fb990", size = 374698, upload-time = "2026-06-14T17:24:48.809Z" }, ] [[package]] @@ -378,7 +381,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ From 750c6ee7216d527cedcbd5b33b4e6178138e9f04 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 1 Aug 2026 23:56:55 +0800 Subject: [PATCH 4/4] release: 0.12.0 (#124) --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 220b908..edde99b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-client-protocol" -version = "0.11.1" +version = "0.12.0" description = "A Python implement of Agent Client Protocol (ACP, by Zed Industries)" authors = [ { name = "Chojan Shang", email = "psiace@apache.org" }, diff --git a/uv.lock b/uv.lock index 5c62ff3..fca93fb 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agent-client-protocol" -version = "0.11.1" +version = "0.12.0" source = { editable = "." } dependencies = [ { name = "pydantic" },