diff --git a/pyproject.toml b/pyproject.toml index b71a716..2b1ff29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-client-protocol" -version = "0.6.2" +version = "0.6.3" description = "A Python implement of Agent Client Protocol (ACP, by Zed Industries)" authors = [{ name = "Chojan Shang", email = "psiace@apache.org" }] readme = "README.md" diff --git a/schema/VERSION b/schema/VERSION index 0e55109..75451e3 100644 --- a/schema/VERSION +++ b/schema/VERSION @@ -1 +1 @@ -refs/tags/v0.6.2 +refs/tags/v0.6.3 diff --git a/schema/schema.json b/schema/schema.json index e94b521..9b39020 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -708,9 +708,12 @@ }, "ContentBlock": { "description": "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content\u2014whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + "discriminator": { + "propertyName": "type" + }, "oneOf": [ { - "description": "Plain text content\n\nAll agents MUST support text content blocks in prompts.", + "description": "Text content. May be plain text or formatted with Markdown.\n\nAll agents MUST support text content blocks in prompts.\nClients SHOULD render this text as Markdown.", "properties": { "_meta": { "description": "Extension point for implementations" @@ -1406,7 +1409,10 @@ "type": "object" } ], - "description": "Configuration for connecting to an MCP (Model Context Protocol) server.\n\nMCP servers provide tools and context that the agent can use when\nprocessing prompts.\n\nSee protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers)" + "description": "Configuration for connecting to an MCP (Model Context Protocol) server.\n\nMCP servers provide tools and context that the agent can use when\nprocessing prompts.\n\nSee protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers)", + "discriminator": { + "propertyName": "type" + } }, "ModelId": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA unique identifier for a model.", @@ -1796,6 +1802,9 @@ }, "RequestPermissionOutcome": { "description": "The outcome of a permission request.", + "discriminator": { + "propertyName": "outcome" + }, "oneOf": [ { "description": "The prompt turn was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel an ongoing\nprompt turn, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", @@ -2060,6 +2069,9 @@ }, "SessionUpdate": { "description": "Different types of updates that can be sent during session processing.\n\nThese updates provide real-time feedback about the agent's progress.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "discriminator": { + "propertyName": "sessionUpdate" + }, "oneOf": [ { "description": "A chunk of the user's message being streamed.", @@ -2530,6 +2542,9 @@ }, "ToolCallContent": { "description": "Content produced by a tool call.\n\nTool calls can produce different types of content including\nstandard content blocks (text, images) or file diffs.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", + "discriminator": { + "propertyName": "type" + }, "oneOf": [ { "description": "Standard content block (text, images, resources).", diff --git a/scripts/gen_schema.py b/scripts/gen_schema.py index b95362e..0badf30 100644 --- a/scripts/gen_schema.py +++ b/scripts/gen_schema.py @@ -3,11 +3,13 @@ import argparse import ast +import json import re import shutil import subprocess import sys from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -25,6 +27,13 @@ re.DOTALL, ) +STDIO_TYPE_LITERAL = 'Literal["2#-datamodel-code-generator-#-object-#-special-#"]' +STDIO_TYPE_PATTERN = re.compile( + r"^ type:\s*Literal\[['\"]2#-datamodel-code-generator-#-object-#-special-#['\"]\]" + r"(?:\s*=\s*['\"][^'\"]+['\"])?\s*$", + re.MULTILINE, +) + # Map of numbered classes produced by datamodel-code-generator to descriptive names. # Keep this in sync with the Rust/TypeScript SDK nomenclature. RENAME_MAP: dict[str, str] = { @@ -109,6 +118,14 @@ ) +@dataclass(frozen=True) +class _ProcessingStep: + """A named transformation applied to the generated schema content.""" + + name: str + apply: Callable[[str], str] + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Generate src/acp/schema.py from the ACP JSON schema.") parser.add_argument( @@ -159,7 +176,7 @@ def generate_schema(*, format_output: bool = True) -> None: ] subprocess.check_call(cmd) # noqa: S603 - warnings = rename_types(SCHEMA_OUT) + warnings = postprocess_generated_schema(SCHEMA_OUT) for warning in warnings: print(f"Warning: {warning}", file=sys.stderr) @@ -167,60 +184,150 @@ def generate_schema(*, format_output: bool = True) -> None: format_with_ruff(SCHEMA_OUT) -def rename_types(output_path: Path) -> list[str]: +def postprocess_generated_schema(output_path: Path) -> list[str]: if not output_path.exists(): raise RuntimeError(f"Generated schema not found at {output_path}") # noqa: TRY003 - content = output_path.read_text(encoding="utf-8") + raw_content = output_path.read_text(encoding="utf-8") + header_block = _build_header_block() + + content = _strip_existing_header(raw_content) + content = _remove_backcompat_block(content) + content, leftover_classes = _rename_numbered_models(content) + + processing_steps: tuple[_ProcessingStep, ...] = ( + _ProcessingStep("apply field overrides", _apply_field_overrides), + _ProcessingStep("apply default overrides", _apply_default_overrides), + _ProcessingStep("normalize stdio literal", _normalize_stdio_model), + _ProcessingStep("attach description comments", _add_description_comments), + _ProcessingStep("ensure custom BaseModel", _ensure_custom_base_model), + ) + + for step in processing_steps: + content = step.apply(content) + + missing_targets = _find_missing_targets(content) + + content = _inject_enum_aliases(content) + alias_block = _build_alias_block() + final_content = header_block + content.rstrip() + "\n\n" + alias_block + if not final_content.endswith("\n"): + final_content += "\n" + output_path.write_text(final_content, encoding="utf-8") + + warnings: list[str] = [] + if leftover_classes: + warnings.append( + "Unrenamed schema models detected: " + + ", ".join(leftover_classes) + + ". Update RENAME_MAP in scripts/gen_schema.py." + ) + if missing_targets: + warnings.append( + "Renamed schema targets not found after generation: " + + ", ".join(sorted(missing_targets)) + + ". Check RENAME_MAP or upstream schema changes." + ) + warnings.extend(_validate_schema_alignment()) + + return warnings + +def _build_header_block() -> str: header_lines = ["# Generated from schema/schema.json. Do not edit by hand."] if VERSION_FILE.exists(): ref = VERSION_FILE.read_text(encoding="utf-8").strip() if ref: header_lines.append(f"# Schema ref: {ref}") + return "\n".join(header_lines) + "\n\n" + + +def _build_alias_block() -> str: + alias_lines = [f"{old} = {new}" for old, new in sorted(RENAME_MAP.items())] + return BACKCOMPAT_MARKER + "\n" + "\n".join(alias_lines) + "\n" + +def _strip_existing_header(content: str) -> str: existing_header = re.match(r"(#.*\n)+", content) if existing_header: - content = content[existing_header.end() :] - content = content.lstrip("\n") + return content[existing_header.end() :].lstrip("\n") + return content.lstrip("\n") + +def _remove_backcompat_block(content: str) -> str: marker_index = content.find(BACKCOMPAT_MARKER) if marker_index != -1: - content = content[:marker_index].rstrip() + return content[:marker_index].rstrip() + return content + +def _rename_numbered_models(content: str) -> tuple[str, list[str]]: + renamed = content for old, new in sorted(RENAME_MAP.items(), key=lambda item: len(item[0]), reverse=True): pattern = re.compile(rf"\b{re.escape(old)}\b") - content = pattern.sub(new, content) + renamed = pattern.sub(new, renamed) leftover_class_pattern = re.compile(r"^class (\w+\d+)\(", re.MULTILINE) - leftover_classes = sorted(set(leftover_class_pattern.findall(content))) + leftover_classes = sorted(set(leftover_class_pattern.findall(renamed))) + return renamed, leftover_classes - header_block = "\n".join(header_lines) + "\n\n" - content = _apply_field_overrides(content) - content = _apply_default_overrides(content) - content = _add_description_comments(content) - content = _ensure_custom_base_model(content) - alias_lines = [f"{old} = {new}" for old, new in sorted(RENAME_MAP.items())] - alias_block = BACKCOMPAT_MARKER + "\n" + "\n".join(alias_lines) + "\n" +def _find_missing_targets(content: str) -> list[str]: + missing: list[str] = [] + for new_name in RENAME_MAP.values(): + pattern = re.compile(rf"^class {re.escape(new_name)}\(", re.MULTILINE) + if not pattern.search(content): + missing.append(new_name) + return missing - content = _inject_enum_aliases(content) - content = header_block + content.rstrip() + "\n\n" + alias_block - if not content.endswith("\n"): - content += "\n" - output_path.write_text(content, encoding="utf-8") +def _validate_schema_alignment() -> list[str]: warnings: list[str] = [] - if leftover_classes: - warnings.append( - "Unrenamed schema models detected: " - + ", ".join(leftover_classes) - + ". Update RENAME_MAP in scripts/gen_schema.py." - ) + if not SCHEMA_JSON.exists(): + warnings.append("schema/schema.json missing; unable to validate enum aliases.") + return warnings + try: + schema_enums = _load_schema_enum_literals() + except json.JSONDecodeError as exc: + warnings.append(f"Failed to parse schema/schema.json: {exc}") + return warnings + + for enum_name, expected_values in ENUM_LITERAL_MAP.items(): + schema_values = schema_enums.get(enum_name) + if schema_values is None: + warnings.append( + f"Enum '{enum_name}' not found in schema.json; update ENUM_LITERAL_MAP or investigate schema changes." + ) + continue + if tuple(schema_values) != expected_values: + warnings.append( + f"Enum mismatch for '{enum_name}': schema.json -> {schema_values}, generated aliases -> {expected_values}" + ) return warnings +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", {}) + enum_literals: dict[str, tuple[str, ...]] = {} + + for name, definition in defs.items(): + values: list[str] = [] + if "enum" in definition: + values = [str(item) for item in definition["enum"]] + elif "oneOf" in definition: + values = [ + str(option["const"]) + for option in definition.get("oneOf", []) + if isinstance(option, dict) and "const" in option + ] + if values: + enum_literals[name] = tuple(values) + + return enum_literals + + def _ensure_custom_base_model(content: str) -> str: if "class BaseModel(_BaseModel):" in content: return content @@ -323,6 +430,19 @@ def replace_block( return content +def _normalize_stdio_model(content: str) -> str: + replacement_line = ' type: Literal["stdio"] = "stdio"' + new_content, count = STDIO_TYPE_PATTERN.subn(replacement_line, content) + if count == 0: + return content + if count > 1: + print( + "Warning: multiple stdio type placeholders detected; manual review required.", + file=sys.stderr, + ) + return new_content + + def _add_description_comments(content: str) -> str: lines = content.splitlines() new_lines: list[str] = [] diff --git a/src/acp/meta.py b/src/acp/meta.py index dad9daf..2b67512 100644 --- a/src/acp/meta.py +++ b/src/acp/meta.py @@ -1,5 +1,5 @@ # Generated from schema/meta.json. Do not edit by hand. -# Schema ref: refs/tags/v0.6.2 +# Schema ref: refs/tags/v0.6.3 AGENT_METHODS = {'authenticate': 'authenticate', 'initialize': 'initialize', 'session_cancel': 'session/cancel', 'session_load': 'session/load', 'session_new': 'session/new', 'session_prompt': 'session/prompt', 'session_set_mode': 'session/set_mode', 'session_set_model': 'session/set_model'} CLIENT_METHODS = {'fs_read_text_file': 'fs/read_text_file', 'fs_write_text_file': 'fs/write_text_file', 'session_request_permission': 'session/request_permission', 'session_update': 'session/update', 'terminal_create': 'terminal/create', 'terminal_kill': 'terminal/kill', 'terminal_output': 'terminal/output', 'terminal_release': 'terminal/release', 'terminal_wait_for_exit': 'terminal/wait_for_exit'} PROTOCOL_VERSION = 1 diff --git a/src/acp/schema.py b/src/acp/schema.py index d9e2da1..4814f08 100644 --- a/src/acp/schema.py +++ b/src/acp/schema.py @@ -1,5 +1,5 @@ # Generated from schema/schema.json. Do not edit by hand. -# Schema ref: refs/tags/v0.6.2 +# Schema ref: refs/tags/v0.6.3 from __future__ import annotations @@ -244,6 +244,7 @@ class StdioMcpServer(BaseModel): ] # Human-readable name identifying this MCP server. name: Annotated[str, Field(description="Human-readable name identifying this MCP server.")] + type: Literal["stdio"] = "stdio" class ModelInfo(BaseModel): @@ -336,7 +337,10 @@ class RequestPermissionResponse(BaseModel): # The user's decision on the permission request. outcome: Annotated[ Union[DeniedOutcome, AllowedOutcome], - Field(description="The user's decision on the permission request."), + Field( + description="The user's decision on the permission request.", + discriminator="outcome", + ), ] @@ -1195,7 +1199,7 @@ class UserMessageChunk(BaseModel): Union[ TextContentBlock, ImageContentBlock, AudioContentBlock, ResourceContentBlock, EmbeddedResourceContentBlock ], - Field(description="A single item of content"), + Field(description="A single item of content", discriminator="type"), ] sessionUpdate: Literal["user_message_chunk"] @@ -1211,7 +1215,7 @@ class AgentMessageChunk(BaseModel): Union[ TextContentBlock, ImageContentBlock, AudioContentBlock, ResourceContentBlock, EmbeddedResourceContentBlock ], - Field(description="A single item of content"), + Field(description="A single item of content", discriminator="type"), ] sessionUpdate: Literal["agent_message_chunk"] @@ -1227,7 +1231,7 @@ class AgentThoughtChunk(BaseModel): Union[ TextContentBlock, ImageContentBlock, AudioContentBlock, ResourceContentBlock, EmbeddedResourceContentBlock ], - Field(description="A single item of content"), + Field(description="A single item of content", discriminator="type"), ] sessionUpdate: Literal["agent_thought_chunk"] @@ -1238,7 +1242,7 @@ class ContentToolCallContent(BaseModel): Union[ TextContentBlock, ImageContentBlock, AudioContentBlock, ResourceContentBlock, EmbeddedResourceContentBlock ], - Field(description="The actual content block."), + Field(description="The actual content block.", discriminator="type"), ] type: Literal["content"] @@ -1457,7 +1461,7 @@ class SessionNotification(BaseModel): AvailableCommandsUpdate, CurrentModeUpdate, ], - Field(description="The actual update content."), + Field(description="The actual update content.", discriminator="sessionUpdate"), ] diff --git a/uv.lock b/uv.lock index 304d15a..ff5c1a4 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10, <4.0" [[package]] name = "agent-client-protocol" -version = "0.6.2" +version = "0.6.3" source = { editable = "." } dependencies = [ { name = "pydantic" },