diff --git a/docs/quickstart.md b/docs/quickstart.md index 0c52c73..319de39 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -157,6 +157,36 @@ update = AgentMessageChunk( ) ``` +## Session notices (unstable) + +The v1 bindings use `schema-v1.23.0`. Clients that can display live advisory +notices advertise `ClientCapabilities(session=ClientSessionCapabilities(notices=NoticeCapabilities()))` +in `initialize`; these models are available from `acp.schema`. + +After checking that capability, agents can send a notice: + +```python +from acp.schema import SessionUpdateNotice + +await client.session_update( + session_id=session_id, + update=SessionUpdateNotice( + severity="warning", + title="Context is nearly full", + description="Start a new session soon.", + ), +) +``` + +Notices are live events, outside session history. The title must be non-empty; +severity accepts `info`, `warning`, `error`, and custom or future strings. +Agents must only send notices to clients that advertise support. + +Tool calls now expose the stable `name` field. Terminal authentication methods +are available as `TerminalAuthMethod`, replacing the incorrect `EnvVarAuthMethod` +name. Accepted elicitation content validates scalar values and string lists; +nested objects are not valid form values. + ## Optional — Talk to the Gemini CLI _Have the Gemini CLI installed? Run the bridge to exercise permission flows._ diff --git a/schema/VERSION b/schema/VERSION index 9514287..cb282c4 100644 --- a/schema/VERSION +++ b/schema/VERSION @@ -1 +1 @@ -refs/tags/schema-v1.21.0 +refs/tags/schema-v1.23.0 diff --git a/schema/schema.json b/schema/schema.json index 62c37e4..041f6ed 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -491,7 +491,7 @@ "x-deserialize-default-on-error": true }, "name": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUpdate the programmatic name of the tool being invoked.\n\nThis field is optional. Omitting it or sending `null` both mean that\nthe existing name is left unchanged.", + "description": "Update the programmatic name of the tool being invoked.\n\nThis field is optional. Omitting it or sending `null` both mean that\nthe existing name is left unchanged.", "type": [ "string", "null" @@ -6012,6 +6012,24 @@ } ] }, + { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nAdvisory information for the user that is not part of session history.\n\nAgents MUST only send this update when the Client advertised\n[`ClientSessionCapabilities::notices`].", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "notice" + } + }, + "required": [ + "sessionUpdate" + ], + "allOf": [ + { + "$ref": "#/$defs/Notice" + } + ] + }, { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nA context compaction has been created or updated.\n\nAgents MUST only send this update when the Client advertised\n[`ClientSessionCapabilities::compaction`].", "type": "object", @@ -6112,7 +6130,7 @@ "type": "string" }, "name": { - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nProgrammatic name of the tool being invoked.\n\nThis field is optional. Omitting it or sending `null` both mean that no\ntool name is available.", + "description": "Programmatic name of the tool being invoked.\n\nThis field is optional. Omitting it or sending `null` both mean that no\ntool name is available.", "type": [ "string", "null" @@ -6758,6 +6776,71 @@ "size" ] }, + "NoticeSeverity": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nSeverity hint for a session notice.", + "anyOf": [ + { + "description": "Informational notice.", + "type": "string", + "const": "info" + }, + { + "description": "Warning notice.", + "type": "string", + "const": "warning" + }, + { + "description": "Error notice.", + "type": "string", + "const": "error" + }, + { + "title": "other", + "description": "Custom or future notice severity.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Other unknown values are reserved for future ACP severities.", + "type": "string" + } + ] + }, + "Notice": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nFire-and-forget advisory information for the user.\n\nNotices are live events rather than session history. Agents must not rely on\na notice being received, displayed, or seen by the user.\nAgents MUST only send notices when the Client advertised\n[`ClientSessionCapabilities::notices`]. Otherwise, Agents may use an agent\nmessage when the information should still be surfaced to the user.\n\nSee RFD: [Session Notices](https://agentclientprotocol.com/rfds/session-notices)", + "type": "object", + "properties": { + "severity": { + "description": "Presentation severity hint.", + "allOf": [ + { + "$ref": "#/$defs/NoticeSeverity" + } + ] + }, + "title": { + "description": "Required non-empty plain-text title that can stand alone.", + "type": "string", + "minLength": 1 + }, + "description": { + "description": "Optional plain-text detail or guidance.\n\nOmitted and `null` are equivalent and mean no description was supplied.", + "type": [ + "string", + "null" + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "Metadata scoped to this notice.\n\nOmitted and `null` are equivalent and mean no metadata was supplied.", + "type": [ + "object", + "null" + ], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": [ + "severity", + "title" + ] + }, "CompactionId": { "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 context compaction within a session.", "type": "string" @@ -7397,6 +7480,18 @@ ], "x-deserialize-default-on-error": true }, + "notices": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nSupport for live advisory `notice` session updates.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means the client can present notices to the user.", + "anyOf": [ + { + "$ref": "#/$defs/NoticeCapabilities" + }, + { + "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)", "type": [ @@ -7454,6 +7549,10 @@ } } }, + "NoticeCapabilities": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nClient support for presenting live advisory notices to the user.", + "type": "object" + }, "PlanCapabilities": { "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nCapabilities for receiving `plan_update` and `plan_removed` session updates.", "type": "object", diff --git a/scripts/_schema_semantics.py b/scripts/_schema_semantics.py index 33ade90..e318919 100644 --- a/scripts/_schema_semantics.py +++ b/scripts/_schema_semantics.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from importlib import import_module from pathlib import Path from typing import Protocol, cast @@ -23,6 +23,7 @@ class SchemaSemantics: base_class: str model_name_map: dict[str, str] compatibility_aliases: str = "" + field_type_overrides: dict[str, str] = field(default_factory=dict) class _SemanticsModule(Protocol): diff --git a/scripts/gen_schema.py b/scripts/gen_schema.py index e790395..dc1c4d0 100644 --- a/scripts/gen_schema.py +++ b/scripts/gen_schema.py @@ -109,6 +109,7 @@ def render_schema(semantics: SchemaSemantics) -> str: infer_union_variant_names=True, naming_strategy=NamingStrategy.PrimaryFirst, model_name_map=semantics.model_name_map, + type_overrides=semantics.field_type_overrides, strict_refs=True, schema_version="2020-12", schema_version_mode=VersionMode.Strict, diff --git a/scripts/gen_schema_v1.py b/scripts/gen_schema_v1.py index d1c382b..d208807 100644 --- a/scripts/gen_schema_v1.py +++ b/scripts/gen_schema_v1.py @@ -20,7 +20,7 @@ for variant_names in ( variant_model_map("AgentResponse", "anyOf", "object", ("AgentResponseMessage", "AgentErrorMessage")), variant_model_map("ClientResponse", "anyOf", "object", ("ClientResponseMessage", "ClientErrorMessage")), - variant_model_map("AuthMethod", "anyOf", "allOf", ("EnvVarAuthMethod", "TerminalAuthMethod")), + variant_model_map("AuthMethod", "anyOf", "allOf", ("TerminalAuthMethod", "AgentAuthMethod")), variant_model_map("McpServer", "anyOf", "allOf", ("HttpMcpServer", "SseMcpServer", "AcpMcpServer")), variant_model_map( "SetSessionConfigOptionRequest", @@ -230,4 +230,6 @@ class Jsonrpc(Enum): base_class="acp._schema_base.BaseModel", model_name_map=MODEL_NAME_MAP, compatibility_aliases=COMPATIBILITY_ALIASES, + # The generator loses additionalProperties types on nullable objects. + field_type_overrides={"ElicitationAcceptAction.content": "acp._schema_base.ElicitationContent"}, ) diff --git a/src/acp/_schema_base.py b/src/acp/_schema_base.py index ad979b3..7719a01 100644 --- a/src/acp/_schema_base.py +++ b/src/acp/_schema_base.py @@ -15,6 +15,8 @@ from ._deserialize import use_default_on_error +ElicitationContent = dict[str, str | int | float | bool | list[str]] + class BaseModel(pydantic.BaseModel): """Runtime behavior shared by generated ACP schema models.""" diff --git a/src/acp/agent/connection.py b/src/acp/agent/connection.py index 51ed409..f521236 100644 --- a/src/acp/agent/connection.py +++ b/src/acp/agent/connection.py @@ -51,6 +51,7 @@ SessionNotification, SessionUpdateCompactionSummaryChunk, SessionUpdateCompactionUpdate, + SessionUpdateNotice, TerminalOutputRequest, TerminalOutputResponse, ToolCallProgress, @@ -150,6 +151,7 @@ async def session_update( | ConfigOptionUpdate | SessionInfoUpdate | UsageUpdate + | SessionUpdateNotice | SessionUpdateCompactionUpdate | SessionUpdateCompactionSummaryChunk, **kwargs: Any, diff --git a/src/acp/interfaces.py b/src/acp/interfaces.py index 5565882..00ff919 100644 --- a/src/acp/interfaces.py +++ b/src/acp/interfaces.py @@ -58,6 +58,7 @@ SessionNotification, SessionUpdateCompactionSummaryChunk, SessionUpdateCompactionUpdate, + SessionUpdateNotice, SetSessionConfigOptionBooleanRequest, SetSessionConfigOptionResponse, SetSessionConfigOptionSelectRequest, @@ -105,6 +106,7 @@ async def session_update( | ConfigOptionUpdate | SessionInfoUpdate | UsageUpdate + | SessionUpdateNotice | SessionUpdateCompactionUpdate | SessionUpdateCompactionSummaryChunk, **kwargs: Any, diff --git a/src/acp/meta.py b/src/acp/meta.py index d852669..0c1565a 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.21.0 +# Schema ref: refs/tags/schema-v1.23.0 AGENT_METHODS = { "initialize": "initialize", "authenticate": "authenticate", diff --git a/src/acp/schema.py b/src/acp/schema.py index e81d860..d5261a0 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/schema-v1.21.0 +# Schema ref: refs/tags/schema-v1.23.0 from __future__ import annotations @@ -7,7 +7,7 @@ from typing import Annotated, Any, Dict, List, Literal, Optional, Union from acp._deserialize import coerce_protocol_version, skip_invalid_items, use_default_on_error -from acp._schema_base import BaseModel +from acp._schema_base import BaseModel, ElicitationContent from pydantic import AnyUrl, ConfigDict, Field, RootModel, ValidationInfo, ValidatorFunctionWrapHandler, field_validator @@ -1573,6 +1573,34 @@ class UsageUpdateBase(BaseModel): """ +class Notice(BaseModel): + severity: Union[Literal["info"], Literal["warning"], Literal["error"], str] + """ + Presentation severity hint. + """ + title: Annotated[str, Field(min_length=1)] + """ + Required non-empty plain-text title that can stand alone. + """ + description: Optional[str] = None + """ + Optional plain-text detail or guidance. + + Omitted and `null` are equivalent and mean no description was supplied. + """ + field_meta: Annotated[Optional[Dict[str, Any]], Field(alias="_meta")] = None + """ + Metadata scoped to this notice. + + Omitted and `null` are equivalent and mean no metadata was supplied. + """ + + @field_validator("description", mode="wrap") + @classmethod + def use_default_on_error_validator(cls, v: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo) -> Any: + return use_default_on_error(v, handler, info) + + class CompleteElicitationNotification(BaseModel): elicitation_id: Annotated[str, Field(alias="elicitationId")] """ @@ -1659,6 +1687,10 @@ class BooleanConfigOptionCapabilities(BaseModel): """ +class NoticeCapabilities(BaseModel): + pass + + class PlanCapabilities(BaseModel): field_meta: Annotated[Optional[Dict[str, Any]], Field(alias="_meta")] = None """ @@ -2405,7 +2437,7 @@ class ElicitationContentValue(RootModel[Union[str, int, float, bool, List[str]]] class ElicitationAcceptAction(BaseModel): - content: Optional[Dict[str, Any]] = None + content: Optional[ElicitationContent] = None """ The user-provided content, if any, as an object matching the requested schema. """ @@ -3226,7 +3258,7 @@ def use_default_on_error_validator(cls, v: Any, handler: ValidatorFunctionWrapHa return use_default_on_error(v, handler, info) -class EnvVarAuthMethod(AuthMethodTerminal): +class TerminalAuthMethod(AuthMethodTerminal): type: Literal["terminal"] = "terminal" @@ -3498,6 +3530,10 @@ def use_default_on_error_validator(cls, v: Any, handler: ValidatorFunctionWrapHa return use_default_on_error(v, handler, info) +class SessionUpdateNotice(Notice): + session_update: Annotated[Literal["notice"], Field(alias="sessionUpdate")] = "notice" + + class PlanEntry(BaseModel): content: str """ @@ -4390,6 +4426,17 @@ class ClientSessionCapabilities(BaseModel): Omitted or `null` both mean the client does not advertise support for any config option extensions. """ + notices: Optional[NoticeCapabilities] = None + """ + **UNSTABLE** + + This capability is not part of the spec yet, and may be removed or changed at any point. + + Support for live advisory `notice` session updates. + + Optional. Omitted or `null` both mean the client does not advertise support. + Supplying `{}` means the client can present notices to the user. + """ field_meta: Annotated[Optional[Dict[str, Any]], Field(alias="_meta")] = None """ The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -4399,7 +4446,7 @@ class ClientSessionCapabilities(BaseModel): See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) """ - @field_validator("compaction", "config_options", mode="wrap") + @field_validator("compaction", "config_options", "notices", mode="wrap") @classmethod def use_default_on_error_validator(cls, v: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo) -> Any: return use_default_on_error(v, handler, info) @@ -4884,10 +4931,6 @@ class ToolCall(BaseModel): """ name: Optional[str] = None """ - **UNSTABLE** - - This capability is not part of the spec yet, and may be removed or changed at any point. - Programmatic name of the tool being invoked. This field is optional. Omitting it or sending `null` both mean that no @@ -5169,10 +5212,6 @@ class ToolCallUpdate(BaseModel): """ name: Optional[str] = None """ - **UNSTABLE** - - This capability is not part of the spec yet, and may be removed or changed at any point. - Update the programmatic name of the tool being invoked. This field is optional. Omitting it or sending `null` both mean that @@ -5551,6 +5590,7 @@ class SessionNotification(BaseModel): ConfigOptionUpdate, SessionInfoUpdate, UsageUpdate, + SessionUpdateNotice, SessionUpdateCompactionUpdate, SessionUpdateCompactionSummaryChunk, ], @@ -5664,7 +5704,7 @@ class InitializeResponse(BaseModel): Capabilities supported by the agent. """ auth_methods: Annotated[ - Optional[List[Union[EnvVarAuthMethod, AuthMethodAgent]]], Field(alias="authMethods", validate_default=True) + Optional[List[Union[TerminalAuthMethod, AuthMethodAgent]]], Field(alias="authMethods", validate_default=True) ] = [] """ Authentication methods supported by the agent. diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 611c6fd..43c37fd 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -37,6 +37,7 @@ AllowedOutcome, AudioContentBlock, ClientCapabilities, + ClientSessionCapabilities, DeniedOutcome, EmbeddedResourceContentBlock, EnvVariable, @@ -46,9 +47,11 @@ Implementation, ListSessionsResponse, McpServerStdio, + NoticeCapabilities, PermissionOption, ResourceContentBlock, ResumeSessionResponse, + SessionUpdateNotice, SseMcpServer, TextContentBlock, ToolCallLocation, @@ -142,6 +145,38 @@ async def test_session_notifications_flow(connect, client): assert client.notifications[0].session_id == "sess" +@pytest.mark.asyncio +async def test_session_notice_reaches_client(connect, client): + agent_side, client_side = connect() + await client_side.initialize( + protocol_version=1, + client_capabilities=ClientCapabilities(session=ClientSessionCapabilities(notices=NoticeCapabilities())), + ) + session = await client_side.new_session(cwd="/workspace") + await agent_side.session_update( + session_id=session.session_id, + update=SessionUpdateNotice( + severity="warning", + title="Context is nearly full", + description="Start a new session soon.", + field_meta={"source": "context-monitor"}, + ), + ) + # A completed prompt also waits for earlier updates for this session. + await client_side.prompt(session_id=session.session_id, prompt=[TextContentBlock(text="Continue")]) + + notification = client.notifications[0] + assert notification.session_id == session.session_id + assert isinstance(notification.update, SessionUpdateNotice) + assert notification.update.model_dump(by_alias=True, exclude_none=True) == { + "sessionUpdate": "notice", + "severity": "warning", + "title": "Context is nearly full", + "description": "Start a new session soon.", + "_meta": {"source": "context-monitor"}, + } + + @pytest.mark.asyncio async def test_response_waits_for_preceding_notification(server): notification_started = asyncio.Event()