Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion schema/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
refs/tags/v0.6.2
refs/tags/v0.6.3
19 changes: 17 additions & 2 deletions schema/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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).",
Expand Down
172 changes: 146 additions & 26 deletions scripts/gen_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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] = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -159,68 +176,158 @@ 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)

if format_output:
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
Expand Down Expand Up @@ -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] = []
Expand Down
2 changes: 1 addition & 1 deletion src/acp/meta.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 11 additions & 7 deletions src/acp/schema.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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",
),
]


Expand Down Expand Up @@ -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"]

Expand All @@ -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"]

Expand All @@ -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"]

Expand All @@ -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"]

Expand Down Expand Up @@ -1457,7 +1461,7 @@ class SessionNotification(BaseModel):
AvailableCommandsUpdate,
CurrentModeUpdate,
],
Field(description="The actual update content."),
Field(description="The actual update content.", discriminator="sessionUpdate"),
]


Expand Down
Loading