diff --git a/.agents/skills/acpremote/SKILL.md b/.agents/skills/acpremote/SKILL.md index 98d854b..fda586a 100644 --- a/.agents/skills/acpremote/SKILL.md +++ b/.agents/skills/acpremote/SKILL.md @@ -50,6 +50,7 @@ If you only need the shortest high-signal path: - bearer-token protection - transport latency metadata/projection - host-ownership policy for mirrored clients +- explicit ACP unstable-protocol opt-in for elicitation routes It does not own: diff --git a/.agents/skills/pydantic-acp/SKILL.md b/.agents/skills/pydantic-acp/SKILL.md index 160ee12..91bc5c6 100644 --- a/.agents/skills/pydantic-acp/SKILL.md +++ b/.agents/skills/pydantic-acp/SKILL.md @@ -1,6 +1,6 @@ --- name: "pydantic-acp" -description: "Use for `pydantic-acp` tasks: exposing `pydantic_ai.Agent` through ACP, adapter config/runtime ownership, prompt capabilities, slash commands, approvals, plans, hooks, projections, host-backed tools, and Pydantic-specific examples." +description: "Use for `pydantic-acp` tasks: exposing `pydantic_ai.Agent` through ACP, adapter config/runtime ownership, extension routing, authentication, prompt capabilities, slash commands, approvals, plans, hooks, projections, host-backed tools, and Pydantic-specific examples." --- # pydantic-acp Skill @@ -24,6 +24,9 @@ In this package that rule affects: - hook visibility - external hook event projection - custom slash commands +- custom ACP extension methods and notifications +- authentication method advertisement and execution +- typed, capability-gated choice elicitation - session replay ## Start Here @@ -46,6 +49,8 @@ If you only need the shortest high-signal path: | hook visibility or external hook projection | Yes | [hooks bridge](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/bridges/hooks.py), [external hooks bridge](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/bridges/external_hooks.py), [hook-introspection runtime](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/runtime/hook_introspection.py), [hook projection module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/hook_projection.py) | | slash commands / model / mode surface | Yes | [custom slash command module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/slash.py), [slash-commands runtime](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/runtime/slash_commands.py), [adapter-prompt runtime](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/runtime/_adapter_prompt.py), [providers module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/providers.py) | | prompt capabilities or multimodal input flags | Yes | [prompt capabilities module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/prompt_capabilities.py), [adapter config module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/config.py), [prompt/resources docs](https://github.com/vcoderun/acpkit/blob/main/docs/pydantic-acp/prompt-resources.md) | +| private ACP methods, notifications, or authentication | Yes | [extensions module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/extensions.py), [runtime adapter](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/runtime/adapter.py), [extensions/auth guide](https://github.com/vcoderun/acpkit/blob/main/docs/pydantic-acp/extensions-and-authentication.md) | +| typed user choices or low-level ACP elicitation | Yes | [elicitation module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/elicitation.py), [session-state module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/session/state.py), [elicitation guide](https://github.com/vcoderun/acpkit/blob/main/docs/pydantic-acp/elicitation.md) | | filesystem search/list projection or tool classification | Yes | [projection module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/projection.py), [host backends docs](https://github.com/vcoderun/acpkit/blob/main/docs/host-backends.md), [projection cookbook](https://github.com/vcoderun/acpkit/blob/main/docs/projection-cookbook.md) | | Codex auth refresh or `auth.json` | No, pair with `codex-auth-helper` | [Codex helper package](https://github.com/vcoderun/acpkit/tree/main/packages/helpers/codex-auth-helper) | | remote hosting or WebSocket transport | No, pair with `acpremote` | [remote transport package](https://github.com/vcoderun/acpkit/tree/main/packages/transports/acpremote) | @@ -63,6 +68,7 @@ It owns: - hook introspection and hook projection - external hook event buffering - custom slash command discovery and handling +- typed extension routing and authentication strategies - host-backed filesystem and terminal ownership - tool projection maps - session store semantics and transcript replay @@ -93,6 +99,7 @@ Package references: - [Raw runtime controls docs](https://raw.githubusercontent.com/vcoderun/acpkit/main/docs/pydantic-acp/runtime-controls.md) - [Raw plans, thinking, and approvals docs](https://raw.githubusercontent.com/vcoderun/acpkit/main/docs/pydantic-acp/plans-thinking-approvals.md) - [Raw prompt/resources docs](https://raw.githubusercontent.com/vcoderun/acpkit/main/docs/pydantic-acp/prompt-resources.md) +- [Raw extensions/authentication docs](https://raw.githubusercontent.com/vcoderun/acpkit/main/docs/pydantic-acp/extensions-and-authentication.md) - [Raw API docs](https://raw.githubusercontent.com/vcoderun/acpkit/main/docs/api/pydantic_acp.md) - [Rendered overview](https://vcoderun.github.io/acpkit/pydantic-acp/) - [Source tree](https://github.com/vcoderun/acpkit/tree/main/packages/adapters/pydantic-acp) @@ -117,6 +124,12 @@ High-value public seams: - `MemorySessionStore` - `FileSessionStore` - `AdapterPromptCapabilities` +- `ExtensionRouter` +- `AuthenticationProvider` +- `AuthenticationMethod` +- `ElicitationChoice` +- `ChoiceElicitationResult` +- `ElicitationUnsupportedError` - `NativeApprovalBridge` - `PermissionToolCallBuilder` - `ApprovalPolicyStore` @@ -141,7 +154,7 @@ Package entrypoint: ## Current Pydantic AI Compatibility -`pydantic-acp` supports `pydantic-ai-slim>=2.9.0,<=2.16.0`. Do not restore +`pydantic-acp` supports `pydantic-ai-slim>=2.9.0,<=2.23.0`. Do not restore Pydantic AI V1 or pre-2.9.0 compatibility, or widen the upper bound without running the runtime and type-check matrix: @@ -162,16 +175,18 @@ When working on this surface, remember: - keep the direct async-iterable fallback only for tests and compatibility fakes - `OpenAICompactionBridge` must not pass deprecated `instructions=` into upstream `OpenAICompaction` - Harness filesystem, shell, and CodeMode bridges are regression-tested against - `pydantic-ai-harness[code-mode]==0.10.0` through its public imports; do not + `pydantic-ai-harness[code-mode]==0.15.0` through its public imports; do not duplicate unrelated Harness capabilities such as Memory or Guardrails in ACP Kit. -- Harness 0.10.0 requires `pydantic-ai-slim>=2.14.1`; use core adapter tests for - 2.9.0 through 2.14.0 and run Harness capability tests on a compatible version. +- Harness 0.15.0 requires a compatible Pydantic AI release; keep Harness + capability tests on the locked development version and use the core adapter + matrix for the full supported range. ## Module Guide | Subsystem | Key files | Use them for | | --- | --- | --- | | public surface and construction | [package entrypoint](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/__init__.py), [adapter config module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/config.py), [prompt capabilities module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/prompt_capabilities.py), [agent source module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/agent_source.py), [agent type definitions](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/agent_types.py), [models module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/models.py), [providers module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/providers.py) | public API shape, construction seams, prompt capability flags, provider contracts | +| protocol extensions and authentication | [extensions module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/extensions.py), [runtime adapter](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/runtime/adapter.py) | custom method and notification routing, auth advertisement, authentication execution | | approvals | [approvals module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/approvals.py), [approval store module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/approval_store.py), [permission presentation module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/permission_presentation.py), [prompt-execution runtime](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/runtime/_prompt_execution.py), [prompt runtime](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/runtime/_prompt_runtime.py) | deferred approvals, remembered policy, permission cards, projection-aware approval context | | bridges | [base bridge module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/bridges/base.py), [capability-support bridge](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/bridges/capability_support.py), [external hooks bridge](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/bridges/external_hooks.py), [history-processor bridge](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/bridges/history_processor.py), [hooks bridge](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/bridges/hooks.py), [MCP bridge](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/bridges/mcp.py), [prepare-tools bridge](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/bridges/prepare_tools.py), [thinking bridge](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/bridges/thinking.py) | optional capability wiring, external event projection, and extension seams | | projection | [projection module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/projection.py), [projection helper module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/projection_helpers.py), [projection text helpers](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/_projection_text.py), [projection risk helpers](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/_projection_risk.py), [hook projection module](https://github.com/vcoderun/acpkit/blob/main/packages/adapters/pydantic-acp/src/pydantic_acp/hook_projection.py) | ACP-visible transcript cards and rendering | @@ -280,6 +295,24 @@ Important rule: Split those concerns before editing. +## Extensions And Authentication + +- `ExtensionRouter` is for application-owned ACP JSON-RPC methods and + notifications that have no focused adapter mapping. +- `AuthenticationProvider` contributes `AuthenticationMethod` values during + initialization and handles `authenticate()`. +- Router-raised `RequestError` values must pass through unchanged. +- The adapter filters `TerminalAuthMethod` unless the client advertises + `auth.terminal=True`. +- Neither strategy receives private adapter runtime objects. Inject + application-owned collaborators directly. +- Do not turn `CapabilityBridge` into generic request middleware and do not use + `ExtensionRouter` to replace plans, providers, approvals, projections, or + host backends. + +Use native `acp.interfaces.Agent` passthrough when most lifecycle behavior is +custom ACP rather than a Pydantic AI runtime projection. + ## Host Ownership This package has the repo's strongest host-side ownership model. @@ -455,6 +488,7 @@ Stay in this skill when the main issue is: - host policy - projection - session lifecycle +- extension routing or adapter authentication ## Guardrails @@ -462,6 +496,11 @@ Stay in this skill when the main issue is: - Depend on `agent-client-protocol==0.11.0`; do not reintroduce `ModelInfo` or wire-level `session/set_model` calls. +- Preserve the no-provider defaults: no advertised auth methods, no-op + `authenticate`, ignored extension notifications, and `method_not_found` for + extension methods. +- Do not catch and rewrite `RequestError` from extension routers or + authentication providers. - Model selection travels through `session/set_config_option` with `config_id="model"`. `AcpProvider.model()` leaves the remote default intact; an explicit provider model requires the remote agent to expose that select diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0f0fd4f..e9ee212 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,25 +1,28 @@ name: Publish Packages on: - push: - tags: - - "v[0-9]*" + release: + types: [published] permissions: contents: read id-token: write concurrency: - group: publish-${{ github.workflow }}-${{ github.ref }} + group: publish-${{ github.workflow }}-${{ github.event.release.tag_name }} cancel-in-progress: false jobs: publish: name: Build And Publish Workspace runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} steps: - name: Check Out Repository uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name }} - name: Set Up Python uses: actions/setup-python@v7 @@ -34,12 +37,12 @@ jobs: - name: Validate, Build, And Smoke Test Release run: | uv sync --frozen --all-extras - make release RELEASE_TAG="${GITHUB_REF_NAME}" + make release RELEASE_TAG="$RELEASE_TAG" - name: Upload Validated Release Artifacts uses: actions/upload-artifact@v7 with: - name: acpkit-${{ github.ref_name }} + name: acpkit-${{ github.event.release.tag_name }} path: dist/* if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8884dd..f8ffae4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,7 +46,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - pydantic-ai-version: ["2.9.0", "2.9.1", "2.10.0", "2.11.0", "2.12.0", "2.13.0", "2.14.0", "2.14.1", "2.15.0", "2.16.0", "2.17.0", "2.18.0", "2.19.0", "2.20.0", "2.21.0", "2.22.0"] + pydantic-ai-version: ["2.9.0", "2.9.1", "2.10.0", "2.11.0", "2.12.0", "2.13.0", "2.14.0", "2.14.1", "2.15.0", "2.16.0", "2.17.0", "2.18.0", "2.19.0", "2.20.0", "2.21.0", "2.22.0", "2.23.0"] steps: - uses: actions/checkout@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index e42e51d..87deae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ ACP Kit uses synchronized versions for `acpkit`, `pydantic-acp`, `langchain-acp` ## [Unreleased] +## [1.6.0] - 2026-08-05 + +### Added + +- `pydantic-acp` now exposes typed `ExtensionRouter` and + `AuthenticationProvider` strategies through `AdapterConfig`, allowing custom + ACP methods, notifications, and authentication without subclassing the + adapter. Defaults remain backward compatible, structured `RequestError` + values pass through unchanged, and ACP 0.11 terminal auth methods are + advertised only to capable clients. +- `AcpSessionContext.ask_choice()` compiles typed single-choice values to ACP + form elicitation, returns distinct accepted, declined, and cancelled results, + validates accepted client responses, and supports an explicit sync or async + fallback for clients without form elicitation. +- `acpremote.TransportOptions.use_unstable_protocol` forwards the ACP SDK opt-in + required by ACP 0.11 elicitation routes on object-based client and server + connections. + +### Changed + +- `pydantic-acp` now supports `pydantic-ai-slim>=2.9.0,<=2.23.0`; runtime, + type-check, and CI compatibility matrices include 2.23.0. + ## [1.5.2] - 2026-08-01 ### Changed diff --git a/COVERAGE b/COVERAGE index 7790f81..8fae96e 100644 --- a/COVERAGE +++ b/COVERAGE @@ -1,2 +1,2 @@ -Line coverage: 100.00% (10403 / 10403) -Branch coverage: 100.00% (3458 / 3458) +Line coverage: 100.00% (10519 / 10519) +Branch coverage: 100.00% (3496 / 3496) diff --git a/Makefile b/Makefile index 666f59d..35cc5ae 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ BLUE := \033[1;34m GREEN := \033[1;32m RESET := \033[0m PYTHON_VERSIONS := 3.11.13 3.12.10 3.13.9 -PYDANTIC_AI_VERSIONS := 2.9.0 2.9.1 2.10.0 2.11.0 2.12.0 2.13.0 2.14.0 2.14.1 2.15.0 2.16.0 2.17.0 2.18.0 2.19.0 2.20.0 2.21.0 2.22.0 +PYDANTIC_AI_VERSIONS := 2.9.0 2.9.1 2.10.0 2.11.0 2.12.0 2.13.0 2.14.0 2.14.1 2.15.0 2.16.0 2.17.0 2.18.0 2.19.0 2.20.0 2.21.0 2.22.0 2.23.0 LANGCHAIN_VERSION := 1.3.11 LANGGRAPH_VERSION := 1.2.7 DEEPAGENTS_VERSION := 0.6.12 diff --git a/VERSION b/VERSION index 4cda8f1..dc1e644 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.2 +1.6.0 diff --git a/docs/acpremote.md b/docs/acpremote.md index 8cd5bf4..d6e9d6e 100644 --- a/docs/acpremote.md +++ b/docs/acpremote.md @@ -142,6 +142,12 @@ agent = connect_acp( ## Typical End-To-End Flows +ACP 0.11 keeps elicitation routes behind the SDK's unstable-protocol flag. For +ACP agents that call `create_elicitation()` or `ask_choice()`, pass +`TransportOptions(use_unstable_protocol=True)` to both `serve_acp()` and +`connect_remote_agent()`. Command relays forward frames unchanged, so the +spawned stdio ACP command must also enable unstable protocol routes. + Remote-host flow: ```bash diff --git a/docs/api/pydantic_acp.md b/docs/api/pydantic_acp.md index caa28e7..03e2969 100644 --- a/docs/api/pydantic_acp.md +++ b/docs/api/pydantic_acp.md @@ -26,6 +26,30 @@ This page documents the public surface re-exported by `pydantic_acp`. ::: pydantic_acp.RuntimeAgent +## Typed Elicitation + +::: pydantic_acp.ElicitationChoice + +::: pydantic_acp.ChoiceElicitationResult + +::: pydantic_acp.ChoiceElicitationAccepted + +::: pydantic_acp.ChoiceElicitationDeclined + +::: pydantic_acp.ChoiceElicitationCancelled + +::: pydantic_acp.ElicitationUnsupportedError + +::: pydantic_acp.InvalidElicitationResponseError + +## Protocol Extension And Authentication Contracts + +::: pydantic_acp.ExtensionRouter + +::: pydantic_acp.AuthenticationProvider + +::: pydantic_acp.AuthenticationMethod + ## ACP Client Provider Bridge ::: pydantic_acp.AcpProvider diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index ea3002f..59d76ff 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -73,7 +73,7 @@ uv add pydantic-acp pip install pydantic-acp ``` -`pydantic-acp` supports `pydantic-ai-slim>=2.9.0,<=2.22.0` and pins the ACP +`pydantic-acp` supports `pydantic-ai-slim>=2.9.0,<=2.23.0` and pins the ACP protocol version it integrates against. Pydantic AI V1 and releases before 2.9.0 are not supported. diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 244867c..f3b2877 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -24,6 +24,8 @@ This file inlines the current documentation corpus so tools and agents can reaso - [Pydantic ACP Overview](https://vcoderun.github.io/acpkit/pydantic-acp/) - [LangChain ACP Overview](https://vcoderun.github.io/acpkit/langchain-acp/) - [AdapterConfig](https://vcoderun.github.io/acpkit/pydantic-acp/adapter-config/) +- [Pydantic ACP Extensions and Authentication](https://vcoderun.github.io/acpkit/pydantic-acp/extensions-and-authentication/) +- [Pydantic ACP Typed Elicitation](https://vcoderun.github.io/acpkit/pydantic-acp/elicitation/) - [Session State and Lifecycle](https://vcoderun.github.io/acpkit/pydantic-acp/session-state/) - [Models, Modes, and Slash Commands](https://vcoderun.github.io/acpkit/pydantic-acp/runtime-controls/) - [Plans, Thinking, and Approvals](https://vcoderun.github.io/acpkit/pydantic-acp/plans-thinking-approvals/) @@ -287,7 +289,7 @@ uv add pydantic-acp pip install pydantic-acp ``` -`pydantic-acp` supports `pydantic-ai-slim>=2.9.0,<=2.22.0` and pins the ACP +`pydantic-acp` supports `pydantic-ai-slim>=2.9.0,<=2.23.0` and pins the ACP protocol version it integrates against. Pydantic AI V1 and releases before 2.9.0 are not supported. @@ -1153,6 +1155,7 @@ Use it when you want ACP-native clients to see truthful: - harness-backed filesystem, shell, and optional CodeMode capability surfaces - prompt resources such as editor selections, branch diffs, file references, and multimodal input - persisted ACP sessions and replayable transcript state +- application-owned extension methods, notifications, and authentication ## The Main Server Integration Seams @@ -1206,6 +1209,13 @@ acp_agent = create_acp_agent(agent=agent) This is the lower-level construction seam behind `run_acp(...)`. +Custom protocol messages and authentication remain opt-in. Configure +`ExtensionRouter` and `AuthenticationProvider` when the adapter should retain +its normal Pydantic AI mappings while delegating those lifecycle surfaces. See +the [Extensions And Authentication guide](https://github.com/vcoderun/acpkit/blob/main/docs/pydantic-acp/extensions-and-authentication.md). +For typed, capability-gated user choices, see the +[Typed Elicitation guide](https://github.com/vcoderun/acpkit/blob/main/docs/pydantic-acp/elicitation.md). + ## ACP Client Provider Bridge Use `create_acp_model(...)` when the thing you already have is an ACP agent or @@ -1513,7 +1523,7 @@ If you are integrating `pydantic-acp` in a real product: ## Version Compatibility And Private Upstream APIs -`pydantic-acp` supports `pydantic-ai-slim>=2.9.0,<=2.22.0`. Pydantic AI V1 and +`pydantic-acp` supports `pydantic-ai-slim>=2.9.0,<=2.23.0`. Pydantic AI V1 and Pydantic AI 2.x releases before 2.9.0 are outside the supported range. Each supported minor is checked against the same adapter runtime suite and @@ -1554,7 +1564,7 @@ aliases and wrapped as `ProcessHistory` capabilities inside What this means in practice: - the adapter is less exposed to private upstream type-module churn -- Pydantic AI 2.9.0 through 2.22.0 share one public adapter contract +- Pydantic AI 2.9.0 through 2.23.0 share one public adapter contract - future Pydantic AI upgrades remain explicit compatibility work - integration points stay isolated behind ACP Kit bridge and runtime seams @@ -2050,7 +2060,9 @@ Use it to decide: | `native_plan_persistence_provider` | `NativePlanPersistenceProvider \| None` | Callback for persisting native ACP plan state | | `approval_bridge` | `ApprovalBridge \| None` | Live ACP approval workflow | | `approval_state_provider` | `ApprovalStateProvider \| None` | Extra approval metadata exposed into session metadata | +| `authentication_provider` | `AuthenticationProvider \| None` | Advertised ACP auth methods and authentication execution | | `capability_bridges` | `Sequence[CapabilityBridge]` | ACP-visible runtime extensions | +| `extension_router` | `ExtensionRouter \| None` | Application-owned ACP extension methods and notifications | | `prompt_capabilities` | `AdapterPromptCapabilities` | ACP prompt capability advertisement for audio, image, and embedded context input | | `slash_command_provider` | `SlashCommandProvider \| None` | Extra host-defined slash commands exposed and handled by the adapter | | `session_store` | `SessionStore` | Backing store for ACP sessions | @@ -2081,6 +2093,18 @@ config = AdapterConfig( This changes ACP initialization metadata only. It does not rewrite prompt parsing rules. +## Protocol Extensions And Authentication + +Use `extension_router` for private or experimental ACP JSON-RPC methods and +notifications. Use `authentication_provider` to advertise typed ACP auth +methods during initialization and handle `authenticate()` calls. These are +protocol lifecycle seams; they are intentionally separate from +`capability_bridges`, which project Pydantic AI runtime capabilities into ACP. + +See the complete [Extensions And Authentication guide](https://github.com/vcoderun/acpkit/blob/main/docs/pydantic-acp/extensions-and-authentication.md) +for implementations, structured error handling, terminal-auth negotiation, and +the bridge/router/native-ACP decision table. + ## Custom Slash Commands Use `slash_command_provider` when the host wants to advertise and handle application-specific slash commands without replacing the built-in `/model`, `/tools`, `/hooks`, `/mcp-servers`, or mode command behavior. @@ -2254,6 +2278,343 @@ For most real integrations: - if `modes_provider` is configured, slash mode commands are derived from that provider’s mode ids - native ACP plan tools only appear when your mode surface actually enables `plan_mode` or `plan_tools` +### Pydantic ACP Extensions and Authentication +URL: https://vcoderun.github.io/acpkit/pydantic-acp/extensions-and-authentication/ +Source: `docs/pydantic-acp/extensions-and-authentication.md` + +# Extensions And Authentication + +`pydantic-acp` keeps protocol-specific escape hatches separate from capability +projection. Use `AdapterConfig.extension_router` for application-owned ACP +extension messages and `AdapterConfig.authentication_provider` for advertised +authentication methods and authentication execution. + +Both seams are optional. Without them, extension methods still return ACP +`method_not_found`, extension notifications are ignored, no authentication +methods are advertised, and `authenticate()` remains a no-op. + +## Extension Routing + +Implement `ExtensionRouter` when an ACP client and your application share a +private or experimental JSON-RPC method that is not represented by a focused +ACP Kit bridge: + +```python +from dataclasses import dataclass, field + +from acp.exceptions import RequestError +from pydantic_acp import ExtensionRouter, JsonValue + +@dataclass(slots=True) +class RuntimeExtensionRouter: + changed_sessions: set[str] = field(default_factory=set) + + async def handle_method( + self, + method: str, + params: dict[str, JsonValue], + ) -> dict[str, JsonValue]: + if method != "acme/runtime-state": + raise RequestError.method_not_found(method) + + session_id = params.get("sessionId") + if not isinstance(session_id, str): + raise RequestError.invalid_params({"field": "sessionId"}) + return { + "sessionId": session_id, + "changed": session_id in self.changed_sessions, + } + + async def handle_notification( + self, + method: str, + params: dict[str, JsonValue], + ) -> None: + if method != "acme/runtime-changed": + return + session_id = params.get("sessionId") + if isinstance(session_id, str): + self.changed_sessions.add(session_id) +``` + +Pass the router through `AdapterConfig`: + +```python +from pydantic_ai import Agent +from pydantic_acp import AdapterConfig, create_acp_agent + +router = RuntimeExtensionRouter() +agent = Agent("openai:gpt-5", name="extension-agent") + +acp_agent = create_acp_agent( + agent=agent, + config=AdapterConfig(extension_router=router), +) +``` + +The adapter forwards JSON-compatible parameters and returns the router's JSON +object unchanged. It does not catch `RequestError`, so `method_not_found`, +`invalid_params`, `auth_required`, and other structured ACP errors retain their +original error code and data across stdio and `acpremote` transports. + +Treat extension parameters as untrusted input. Validate required fields in the +router, namespace private method names, and include an application-level schema +version when the payload will evolve independently of ACP. + +## Authentication Providers + +`AuthenticationProvider` owns two related operations: + +- `get_auth_methods(client_capabilities)` contributes typed ACP auth methods to + `InitializeResponse.auth_methods`. +- `authenticate(method_id)` executes the selected method and returns an + optional `AuthenticateResponse`. + +Methods can be agent-managed, environment-variable based, or terminal based. +The provider methods may be synchronous or asynchronous. + +```python +import os + +from acp.exceptions import RequestError +from acp.schema import ( + AuthenticateResponse, + AuthEnvVar, + ClientCapabilities, + EnvVarAuthMethod, +) +from pydantic_acp import AuthenticationMethod + +class EnvironmentAuthenticationProvider: + def get_auth_methods( + self, + client_capabilities: ClientCapabilities | None, + ) -> tuple[AuthenticationMethod, ...]: + del client_capabilities + return ( + EnvVarAuthMethod( + id="acme-token", + name="Acme API token", + description="Authenticate the ACP agent with an Acme token.", + vars=[AuthEnvVar(name="ACME_API_TOKEN", label="API token")], + type="env_var", + ), + ) + + async def authenticate(self, method_id: str) -> AuthenticateResponse: + if method_id != "acme-token": + raise RequestError.invalid_params({"methodId": method_id}) + if not os.environ.get("ACME_API_TOKEN"): + raise RequestError.auth_required() + return AuthenticateResponse() +``` + +```python +from pydantic_acp import AdapterConfig + +config = AdapterConfig( + authentication_provider=EnvironmentAuthenticationProvider(), +) +``` + +ACP 0.11 allows `TerminalAuthMethod` advertisement only when the client reports +`client_capabilities.auth.terminal=True`. The adapter enforces that rule by +filtering terminal methods for clients that do not advertise support. The full +`ClientCapabilities` value is still passed to the provider so it can make +additional application-specific choices. + +Do not put credentials in auth method metadata, extension results, logs, or +session metadata. Auth method descriptors advertise how authentication works; +secret acquisition and storage remain client or application responsibilities. + +## Choosing The Correct Seam + +| Requirement | Use | Why | +|---|---|---| +| Project Pydantic AI capabilities, tools, modes, plans, or metadata into ACP | `CapabilityBridge` or another focused bridge | Bridges translate known runtime semantics and participate in adapter-managed session behavior. | +| Handle a private or experimental JSON-RPC method or notification | `ExtensionRouter` | The router is a narrow protocol escape hatch and preserves ACP error semantics. | +| Advertise and execute application authentication | `AuthenticationProvider` | Authentication is lifecycle state, not tool or capability projection. | +| Implement an agent whose protocol behavior is mostly custom ACP | Native `acp.interfaces.Agent` | Native passthrough avoids forcing application-specific lifecycle rules through the Pydantic adapter. | + +Do not use an extension router to replace an existing bridge, provider, session +store, approval flow, or host backend. When an experimental ACP message gains a +dedicated stable adapter mapping, migrate to that focused surface so capability +advertisement and session ownership remain truthful. + +## Session Interaction + +An extension payload may carry a public ACP session id, but the router does not +receive `PydanticAcpAgent` or any other private runtime object. Inject +application-owned collaborators directly into the router. For prompt-time user +interaction, use the public `AcpSessionContext.client`, capability predicates, +`create_elicitation()`, and `ask_choice()` APIs from agent factories, providers, +slash commands, or bridges. + +This separation keeps custom protocol routing independent from adapter session +internals and makes the same router behavior testable over local stdio and +`acpremote` forwarding. + +The router intentionally receives only `method` and validated JSON-compatible +`params`. ACP Kit does not currently expose an `ExtensionContext`: no concrete +extension flow requires negotiated protocol state, client capabilities, or +client callbacks. If that need appears, it should be served by a typed public +context rather than exposing private adapter state. + +There is also no catch-all lifecycle middleware. Authentication belongs to +`AuthenticationProvider`; models, modes, config, plans, sessions, and prompt +behavior remain in their focused provider and bridge seams. Add a lifecycle +hook only for a concrete operation that those contracts cannot represent. + +### Pydantic ACP Typed Elicitation +URL: https://vcoderun.github.io/acpkit/pydantic-acp/elicitation/ +Source: `docs/pydantic-acp/elicitation.md` + +# Typed Elicitation + +`AcpSessionContext.ask_choice()` asks the connected ACP client to select one +typed application value. It is an ergonomic layer over +`AcpSessionContext.create_elicitation()`; the low-level API remains available +for custom ACP form and URL elicitation. + +The helper does not introduce another schema system. It compiles choices to +ACP's existing `ElicitationFormSessionMode`, `ElicitationSchema`, and +`ElicitationStringPropertySchema` types. + +## Single-Choice Questions + +Each `ElicitationChoice` carries an application value, a user-visible label, +an optional description, and whether it is the default: + +```python +from pydantic_acp import ( + AcpSessionContext, + ChoiceElicitationAccepted, + ChoiceElicitationCancelled, + ChoiceElicitationDeclined, + ElicitationChoice, +) + +async def choose_target(session: AcpSessionContext) -> str: + result = await session.ask_choice( + "Choose a deployment target", + [ + ElicitationChoice(value="preview", label="Preview"), + ElicitationChoice( + value="production", + label="Production", + description="Deploy to the production environment.", + default=True, + ), + ], + ) + + if isinstance(result, ChoiceElicitationAccepted): + return f"Selected: {result.value}" + if isinstance(result, ChoiceElicitationDeclined): + return "The user declined to choose." + if isinstance(result, ChoiceElicitationCancelled): + return "The choice was cancelled." + raise AssertionError("unreachable") +``` + +The value is not sent as the ACP enum constant. ACP Kit sends opaque string +tokens and maps the accepted token back to the original typed value. Values +can therefore be enums, dataclasses, identifiers, or other application types; +they do not need to be JSON serializable. + +Exactly zero or one choice may have `default=True`. A default is transmitted +through the ACP property schema. It does not turn a malformed accepted response +into a valid answer: an accepted response must still contain one of the offered +tokens. + +## Result Contract + +The result is a discriminated union. Inspect `result.status` or narrow with the +public result classes: + +| Status | Type | Meaning | +| --- | --- | --- | +| `accepted` | `ChoiceElicitationAccepted[T]` | The user selected a choice; `.value` contains the typed value. | +| `declined` | `ChoiceElicitationDeclined` | The user explicitly declined to answer. | +| `cancelled` | `ChoiceElicitationCancelled` | The interaction was cancelled without an answer. | + +`None` is never used to collapse these outcomes. If a client returns an +accepted response without a known choice token, ACP Kit raises +`InvalidElicitationResponseError`. + +## Capability Negotiation And Fallbacks + +`ask_choice()` always checks `session.supports_elicitation(mode)` before +sending the request. It never substitutes ACP permission requests for +elicitation. Without form support it raises `ElicitationUnsupportedError`: + +```python +from pydantic_acp import ElicitationChoice, ElicitationUnsupportedError + +try: + result = await session.ask_choice( + "Continue?", + [ElicitationChoice(value=True, label="Continue")], + ) +except ElicitationUnsupportedError: + result = None +``` + +When the application has a deliberate non-ACP fallback, pass a synchronous or +asynchronous callable. Its typed value is returned as an accepted result, and +the fallback runs only when the client lacks form elicitation: + +```python +async def configured_default() -> str: + return "preview" + +result = await session.ask_choice( + "Choose a deployment target", + [ + ElicitationChoice(value="preview", label="Preview", default=True), + ElicitationChoice(value="production", label="Production"), + ], + fallback=configured_default, +) +``` + +Use a fallback only when proceeding without user input is valid. Do not use it +to pretend an unsupported client collected consent. + +## Presentation Contract + +ACP Kit transmits the question, option labels, schema default, and typed-choice +semantics. The client owns visual layout and interaction design. Option +descriptions are sent as namespaced option metadata because ACP 0.11 has no +standard per-option description field; clients may ignore that metadata. + +The helper intentionally provides no guarantee about radio buttons, menus, +dialogs, ordering beyond the schema order, or whether descriptions are visible. + +## Remote Transport + +ACP 0.11 marks elicitation routes as unstable. Enable them explicitly on both +ends of an `acpremote` object connection: + +```python +from acpremote import TransportOptions, connect_remote_agent, serve_acp + +options = TransportOptions(use_unstable_protocol=True) +server = await serve_acp(acp_agent, options=options) +remote = await connect_remote_agent(client, websocket_url, options=options) +``` + +For `serve_command()`, the relay transports JSON-RPC frames unchanged. The +spawned ACP command and the connecting `acpremote` client must each enable the +ACP SDK's unstable protocol routes. + +## Low-Level Elicitation + +Use `create_elicitation()` directly for multi-field forms, URL elicitation, or +schema shapes other than one typed choice. Check `supports_elicitation(mode)` +first and construct the ACP SDK mode explicitly. `ask_choice()` is deliberately +single-select; it does not infer multi-select behavior from collection values. + ### Session State and Lifecycle URL: https://vcoderun.github.io/acpkit/pydantic-acp/session-state/ Source: `docs/pydantic-acp/session-state.md` @@ -5905,6 +6266,12 @@ agent = connect_acp( ## Typical End-To-End Flows +ACP 0.11 keeps elicitation routes behind the SDK's unstable-protocol flag. For +ACP agents that call `create_elicitation()` or `ask_choice()`, pass +`TransportOptions(use_unstable_protocol=True)` to both `serve_acp()` and +`connect_remote_agent()`. Command relays forward frames unchanged, so the +spawned stdio ACP command must also enable unstable protocol routes. + Remote-host flow: ```bash @@ -6699,6 +7066,30 @@ This page documents the public surface re-exported by `pydantic_acp`. ::: pydantic_acp.RuntimeAgent +## Typed Elicitation + +::: pydantic_acp.ElicitationChoice + +::: pydantic_acp.ChoiceElicitationResult + +::: pydantic_acp.ChoiceElicitationAccepted + +::: pydantic_acp.ChoiceElicitationDeclined + +::: pydantic_acp.ChoiceElicitationCancelled + +::: pydantic_acp.ElicitationUnsupportedError + +::: pydantic_acp.InvalidElicitationResponseError + +## Protocol Extension And Authentication Contracts + +::: pydantic_acp.ExtensionRouter + +::: pydantic_acp.AuthenticationProvider + +::: pydantic_acp.AuthenticationMethod + ## ACP Client Provider Bridge ::: pydantic_acp.AcpProvider @@ -6972,7 +7363,7 @@ make check-pydantic-ai-matrix ``` This runs the Pydantic adapter runtime tests and type checks independently -against Pydantic AI 2.9.0 through 2.22.0. The same matrix runs +against Pydantic AI 2.9.0 through 2.23.0. The same matrix runs in CI. Add a version only after both parts pass. LangChain stack compatibility checks: diff --git a/docs/llms.txt b/docs/llms.txt index 2b9f042..aff5cae 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -42,6 +42,12 @@ Today the repo ships production-grade adapters for both Pydantic AI and the Lang - [AdapterConfig](https://vcoderun.github.io/acpkit/pydantic-acp/adapter-config/) Source: `docs/pydantic-acp/adapter-config.md` Summary: Field-by-field guide to runtime configuration, prompt capabilities, ownership, and adapter behavior. +- [Pydantic ACP Extensions and Authentication](https://vcoderun.github.io/acpkit/pydantic-acp/extensions-and-authentication/) + Source: `docs/pydantic-acp/extensions-and-authentication.md` + Summary: Typed extension routing, authentication providers, structured ACP errors, and bridge-versus-native ownership guidance. +- [Pydantic ACP Typed Elicitation](https://vcoderun.github.io/acpkit/pydantic-acp/elicitation/) + Source: `docs/pydantic-acp/elicitation.md` + Summary: Typed single-choice elicitation, capability negotiation, discriminated outcomes, explicit fallbacks, and remote transport requirements. - [Session State and Lifecycle](https://vcoderun.github.io/acpkit/pydantic-acp/session-state/) Source: `docs/pydantic-acp/session-state.md` Summary: Session stores, replay semantics, persistence, and state transitions. diff --git a/docs/pydantic-acp.md b/docs/pydantic-acp.md index 43f6486..c76dac7 100644 --- a/docs/pydantic-acp.md +++ b/docs/pydantic-acp.md @@ -15,6 +15,7 @@ Use it when you want ACP-native clients to see truthful: - harness-backed filesystem, shell, and optional CodeMode capability surfaces - prompt resources such as editor selections, branch diffs, file references, and multimodal input - persisted ACP sessions and replayable transcript state +- application-owned extension methods, notifications, and authentication ## The Main Server Integration Seams @@ -68,6 +69,13 @@ acp_agent = create_acp_agent(agent=agent) This is the lower-level construction seam behind `run_acp(...)`. +Custom protocol messages and authentication remain opt-in. Configure +`ExtensionRouter` and `AuthenticationProvider` when the adapter should retain +its normal Pydantic AI mappings while delegating those lifecycle surfaces. See +the [Extensions And Authentication guide](https://github.com/vcoderun/acpkit/blob/main/docs/pydantic-acp/extensions-and-authentication.md). +For typed, capability-gated user choices, see the +[Typed Elicitation guide](https://github.com/vcoderun/acpkit/blob/main/docs/pydantic-acp/elicitation.md). + ## ACP Client Provider Bridge Use `create_acp_model(...)` when the thing you already have is an ACP agent or @@ -379,7 +387,7 @@ If you are integrating `pydantic-acp` in a real product: ## Version Compatibility And Private Upstream APIs -`pydantic-acp` supports `pydantic-ai-slim>=2.9.0,<=2.22.0`. Pydantic AI V1 and +`pydantic-acp` supports `pydantic-ai-slim>=2.9.0,<=2.23.0`. Pydantic AI V1 and Pydantic AI 2.x releases before 2.9.0 are outside the supported range. Each supported minor is checked against the same adapter runtime suite and @@ -420,6 +428,6 @@ aliases and wrapped as `ProcessHistory` capabilities inside What this means in practice: - the adapter is less exposed to private upstream type-module churn -- Pydantic AI 2.9.0 through 2.22.0 share one public adapter contract +- Pydantic AI 2.9.0 through 2.23.0 share one public adapter contract - future Pydantic AI upgrades remain explicit compatibility work - integration points stay isolated behind ACP Kit bridge and runtime seams diff --git a/docs/pydantic-acp/adapter-config.md b/docs/pydantic-acp/adapter-config.md index c214883..eec9834 100644 --- a/docs/pydantic-acp/adapter-config.md +++ b/docs/pydantic-acp/adapter-config.md @@ -26,7 +26,9 @@ Use it to decide: | `native_plan_persistence_provider` | `NativePlanPersistenceProvider \| None` | Callback for persisting native ACP plan state | | `approval_bridge` | `ApprovalBridge \| None` | Live ACP approval workflow | | `approval_state_provider` | `ApprovalStateProvider \| None` | Extra approval metadata exposed into session metadata | +| `authentication_provider` | `AuthenticationProvider \| None` | Advertised ACP auth methods and authentication execution | | `capability_bridges` | `Sequence[CapabilityBridge]` | ACP-visible runtime extensions | +| `extension_router` | `ExtensionRouter \| None` | Application-owned ACP extension methods and notifications | | `prompt_capabilities` | `AdapterPromptCapabilities` | ACP prompt capability advertisement for audio, image, and embedded context input | | `slash_command_provider` | `SlashCommandProvider \| None` | Extra host-defined slash commands exposed and handled by the adapter | | `session_store` | `SessionStore` | Backing store for ACP sessions | @@ -57,6 +59,18 @@ config = AdapterConfig( This changes ACP initialization metadata only. It does not rewrite prompt parsing rules. +## Protocol Extensions And Authentication + +Use `extension_router` for private or experimental ACP JSON-RPC methods and +notifications. Use `authentication_provider` to advertise typed ACP auth +methods during initialization and handle `authenticate()` calls. These are +protocol lifecycle seams; they are intentionally separate from +`capability_bridges`, which project Pydantic AI runtime capabilities into ACP. + +See the complete [Extensions And Authentication guide](https://github.com/vcoderun/acpkit/blob/main/docs/pydantic-acp/extensions-and-authentication.md) +for implementations, structured error handling, terminal-auth negotiation, and +the bridge/router/native-ACP decision table. + ## Custom Slash Commands Use `slash_command_provider` when the host wants to advertise and handle application-specific slash commands without replacing the built-in `/model`, `/tools`, `/hooks`, `/mcp-servers`, or mode command behavior. diff --git a/docs/pydantic-acp/elicitation.md b/docs/pydantic-acp/elicitation.md new file mode 100644 index 0000000..f4b5a16 --- /dev/null +++ b/docs/pydantic-acp/elicitation.md @@ -0,0 +1,147 @@ +# Typed Elicitation + +`AcpSessionContext.ask_choice()` asks the connected ACP client to select one +typed application value. It is an ergonomic layer over +`AcpSessionContext.create_elicitation()`; the low-level API remains available +for custom ACP form and URL elicitation. + +The helper does not introduce another schema system. It compiles choices to +ACP's existing `ElicitationFormSessionMode`, `ElicitationSchema`, and +`ElicitationStringPropertySchema` types. + +## Single-Choice Questions + +Each `ElicitationChoice` carries an application value, a user-visible label, +an optional description, and whether it is the default: + +```python +from pydantic_acp import ( + AcpSessionContext, + ChoiceElicitationAccepted, + ChoiceElicitationCancelled, + ChoiceElicitationDeclined, + ElicitationChoice, +) + + +async def choose_target(session: AcpSessionContext) -> str: + result = await session.ask_choice( + "Choose a deployment target", + [ + ElicitationChoice(value="preview", label="Preview"), + ElicitationChoice( + value="production", + label="Production", + description="Deploy to the production environment.", + default=True, + ), + ], + ) + + if isinstance(result, ChoiceElicitationAccepted): + return f"Selected: {result.value}" + if isinstance(result, ChoiceElicitationDeclined): + return "The user declined to choose." + if isinstance(result, ChoiceElicitationCancelled): + return "The choice was cancelled." + raise AssertionError("unreachable") +``` + +The value is not sent as the ACP enum constant. ACP Kit sends opaque string +tokens and maps the accepted token back to the original typed value. Values +can therefore be enums, dataclasses, identifiers, or other application types; +they do not need to be JSON serializable. + +Exactly zero or one choice may have `default=True`. A default is transmitted +through the ACP property schema. It does not turn a malformed accepted response +into a valid answer: an accepted response must still contain one of the offered +tokens. + +## Result Contract + +The result is a discriminated union. Inspect `result.status` or narrow with the +public result classes: + +| Status | Type | Meaning | +| --- | --- | --- | +| `accepted` | `ChoiceElicitationAccepted[T]` | The user selected a choice; `.value` contains the typed value. | +| `declined` | `ChoiceElicitationDeclined` | The user explicitly declined to answer. | +| `cancelled` | `ChoiceElicitationCancelled` | The interaction was cancelled without an answer. | + +`None` is never used to collapse these outcomes. If a client returns an +accepted response without a known choice token, ACP Kit raises +`InvalidElicitationResponseError`. + +## Capability Negotiation And Fallbacks + +`ask_choice()` always checks `session.supports_elicitation(mode)` before +sending the request. It never substitutes ACP permission requests for +elicitation. Without form support it raises `ElicitationUnsupportedError`: + +```python +from pydantic_acp import ElicitationChoice, ElicitationUnsupportedError + +try: + result = await session.ask_choice( + "Continue?", + [ElicitationChoice(value=True, label="Continue")], + ) +except ElicitationUnsupportedError: + result = None +``` + +When the application has a deliberate non-ACP fallback, pass a synchronous or +asynchronous callable. Its typed value is returned as an accepted result, and +the fallback runs only when the client lacks form elicitation: + +```python +async def configured_default() -> str: + return "preview" + + +result = await session.ask_choice( + "Choose a deployment target", + [ + ElicitationChoice(value="preview", label="Preview", default=True), + ElicitationChoice(value="production", label="Production"), + ], + fallback=configured_default, +) +``` + +Use a fallback only when proceeding without user input is valid. Do not use it +to pretend an unsupported client collected consent. + +## Presentation Contract + +ACP Kit transmits the question, option labels, schema default, and typed-choice +semantics. The client owns visual layout and interaction design. Option +descriptions are sent as namespaced option metadata because ACP 0.11 has no +standard per-option description field; clients may ignore that metadata. + +The helper intentionally provides no guarantee about radio buttons, menus, +dialogs, ordering beyond the schema order, or whether descriptions are visible. + +## Remote Transport + +ACP 0.11 marks elicitation routes as unstable. Enable them explicitly on both +ends of an `acpremote` object connection: + +```python +from acpremote import TransportOptions, connect_remote_agent, serve_acp + +options = TransportOptions(use_unstable_protocol=True) +server = await serve_acp(acp_agent, options=options) +remote = await connect_remote_agent(client, websocket_url, options=options) +``` + +For `serve_command()`, the relay transports JSON-RPC frames unchanged. The +spawned ACP command and the connecting `acpremote` client must each enable the +ACP SDK's unstable protocol routes. + +## Low-Level Elicitation + +Use `create_elicitation()` directly for multi-field forms, URL elicitation, or +schema shapes other than one typed choice. Check `supports_elicitation(mode)` +first and construct the ACP SDK mode explicitly. `ask_choice()` is deliberately +single-select; it does not infer multi-select behavior from collection values. diff --git a/docs/pydantic-acp/extensions-and-authentication.md b/docs/pydantic-acp/extensions-and-authentication.md new file mode 100644 index 0000000..920db93 --- /dev/null +++ b/docs/pydantic-acp/extensions-and-authentication.md @@ -0,0 +1,184 @@ +# Extensions And Authentication + +`pydantic-acp` keeps protocol-specific escape hatches separate from capability +projection. Use `AdapterConfig.extension_router` for application-owned ACP +extension messages and `AdapterConfig.authentication_provider` for advertised +authentication methods and authentication execution. + +Both seams are optional. Without them, extension methods still return ACP +`method_not_found`, extension notifications are ignored, no authentication +methods are advertised, and `authenticate()` remains a no-op. + +## Extension Routing + +Implement `ExtensionRouter` when an ACP client and your application share a +private or experimental JSON-RPC method that is not represented by a focused +ACP Kit bridge: + +```python +from dataclasses import dataclass, field + +from acp.exceptions import RequestError +from pydantic_acp import ExtensionRouter, JsonValue + + +@dataclass(slots=True) +class RuntimeExtensionRouter: + changed_sessions: set[str] = field(default_factory=set) + + async def handle_method( + self, + method: str, + params: dict[str, JsonValue], + ) -> dict[str, JsonValue]: + if method != "acme/runtime-state": + raise RequestError.method_not_found(method) + + session_id = params.get("sessionId") + if not isinstance(session_id, str): + raise RequestError.invalid_params({"field": "sessionId"}) + return { + "sessionId": session_id, + "changed": session_id in self.changed_sessions, + } + + async def handle_notification( + self, + method: str, + params: dict[str, JsonValue], + ) -> None: + if method != "acme/runtime-changed": + return + session_id = params.get("sessionId") + if isinstance(session_id, str): + self.changed_sessions.add(session_id) +``` + +Pass the router through `AdapterConfig`: + +```python +from pydantic_ai import Agent +from pydantic_acp import AdapterConfig, create_acp_agent + +router = RuntimeExtensionRouter() +agent = Agent("openai:gpt-5", name="extension-agent") + +acp_agent = create_acp_agent( + agent=agent, + config=AdapterConfig(extension_router=router), +) +``` + +The adapter forwards JSON-compatible parameters and returns the router's JSON +object unchanged. It does not catch `RequestError`, so `method_not_found`, +`invalid_params`, `auth_required`, and other structured ACP errors retain their +original error code and data across stdio and `acpremote` transports. + +Treat extension parameters as untrusted input. Validate required fields in the +router, namespace private method names, and include an application-level schema +version when the payload will evolve independently of ACP. + +## Authentication Providers + +`AuthenticationProvider` owns two related operations: + +- `get_auth_methods(client_capabilities)` contributes typed ACP auth methods to + `InitializeResponse.auth_methods`. +- `authenticate(method_id)` executes the selected method and returns an + optional `AuthenticateResponse`. + +Methods can be agent-managed, environment-variable based, or terminal based. +The provider methods may be synchronous or asynchronous. + +```python +import os + +from acp.exceptions import RequestError +from acp.schema import ( + AuthenticateResponse, + AuthEnvVar, + ClientCapabilities, + EnvVarAuthMethod, +) +from pydantic_acp import AuthenticationMethod + + +class EnvironmentAuthenticationProvider: + def get_auth_methods( + self, + client_capabilities: ClientCapabilities | None, + ) -> tuple[AuthenticationMethod, ...]: + del client_capabilities + return ( + EnvVarAuthMethod( + id="acme-token", + name="Acme API token", + description="Authenticate the ACP agent with an Acme token.", + vars=[AuthEnvVar(name="ACME_API_TOKEN", label="API token")], + type="env_var", + ), + ) + + async def authenticate(self, method_id: str) -> AuthenticateResponse: + if method_id != "acme-token": + raise RequestError.invalid_params({"methodId": method_id}) + if not os.environ.get("ACME_API_TOKEN"): + raise RequestError.auth_required() + return AuthenticateResponse() +``` + +```python +from pydantic_acp import AdapterConfig + +config = AdapterConfig( + authentication_provider=EnvironmentAuthenticationProvider(), +) +``` + +ACP 0.11 allows `TerminalAuthMethod` advertisement only when the client reports +`client_capabilities.auth.terminal=True`. The adapter enforces that rule by +filtering terminal methods for clients that do not advertise support. The full +`ClientCapabilities` value is still passed to the provider so it can make +additional application-specific choices. + +Do not put credentials in auth method metadata, extension results, logs, or +session metadata. Auth method descriptors advertise how authentication works; +secret acquisition and storage remain client or application responsibilities. + +## Choosing The Correct Seam + +| Requirement | Use | Why | +|---|---|---| +| Project Pydantic AI capabilities, tools, modes, plans, or metadata into ACP | `CapabilityBridge` or another focused bridge | Bridges translate known runtime semantics and participate in adapter-managed session behavior. | +| Handle a private or experimental JSON-RPC method or notification | `ExtensionRouter` | The router is a narrow protocol escape hatch and preserves ACP error semantics. | +| Advertise and execute application authentication | `AuthenticationProvider` | Authentication is lifecycle state, not tool or capability projection. | +| Implement an agent whose protocol behavior is mostly custom ACP | Native `acp.interfaces.Agent` | Native passthrough avoids forcing application-specific lifecycle rules through the Pydantic adapter. | + +Do not use an extension router to replace an existing bridge, provider, session +store, approval flow, or host backend. When an experimental ACP message gains a +dedicated stable adapter mapping, migrate to that focused surface so capability +advertisement and session ownership remain truthful. + +## Session Interaction + +An extension payload may carry a public ACP session id, but the router does not +receive `PydanticAcpAgent` or any other private runtime object. Inject +application-owned collaborators directly into the router. For prompt-time user +interaction, use the public `AcpSessionContext.client`, capability predicates, +`create_elicitation()`, and `ask_choice()` APIs from agent factories, providers, +slash commands, or bridges. + +This separation keeps custom protocol routing independent from adapter session +internals and makes the same router behavior testable over local stdio and +`acpremote` forwarding. + +The router intentionally receives only `method` and validated JSON-compatible +`params`. ACP Kit does not currently expose an `ExtensionContext`: no concrete +extension flow requires negotiated protocol state, client capabilities, or +client callbacks. If that need appears, it should be served by a typed public +context rather than exposing private adapter state. + +There is also no catch-all lifecycle middleware. Authentication belongs to +`AuthenticationProvider`; models, modes, config, plans, sessions, and prompt +behavior remain in their focused provider and bridge seams. Add a lifecycle +hook only for a concrete operation that those contracts cannot represent. diff --git a/docs/pydantic-acp/harness-capabilities.md b/docs/pydantic-acp/harness-capabilities.md index 0e8c50f..8e06966 100644 --- a/docs/pydantic-acp/harness-capabilities.md +++ b/docs/pydantic-acp/harness-capabilities.md @@ -16,7 +16,7 @@ such as Memory and Guardrails remain available to the underlying agent without being reimplemented as ACP Kit-specific tool bridges. Harness 0.15.0 itself requires `pydantic-ai-slim>=2.22.0`. The core -`pydantic-acp` adapter remains compatible with Pydantic AI 2.9.0 through 2.22.0; +`pydantic-acp` adapter remains compatible with Pydantic AI 2.9.0 through 2.23.0; install the `harness` extra only when the resolved Pydantic AI version is in the Harness-supported part of that range. diff --git a/docs/testing.md b/docs/testing.md index adf0276..aa3a1e8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -49,7 +49,7 @@ make check-pydantic-ai-matrix ``` This runs the Pydantic adapter runtime tests and type checks independently -against Pydantic AI 2.9.0 through 2.22.0. The same matrix runs +against Pydantic AI 2.9.0 through 2.23.0. The same matrix runs in CI. Add a version only after both parts pass. LangChain stack compatibility checks: diff --git a/docs/versioning.md b/docs/versioning.md index c0c732a..4f2ec6e 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -47,7 +47,7 @@ The v1 baseline is: | Integration | Supported baseline | |---|---| | Python | 3.11, 3.12, 3.13 | -| Pydantic AI | 2.9.0 through 2.22.0 | +| Pydantic AI | 2.9.0 through 2.23.0 | | LangChain | 1.3.11 | | LangGraph | 1.2.7 | | DeepAgents | 0.6.12 | diff --git a/mkdocs.yml b/mkdocs.yml index 1c8515f..41b78f3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,6 +93,8 @@ nav: - Pydantic ACP: - Overview: pydantic-acp.md - AdapterConfig: pydantic-acp/adapter-config.md + - Extensions and Authentication: pydantic-acp/extensions-and-authentication.md + - Typed Elicitation: pydantic-acp/elicitation.md - Session State and Lifecycle: pydantic-acp/session-state.md - Models, Modes, and Slash Commands: pydantic-acp/runtime-controls.md - Plans, Thinking, and Approvals: pydantic-acp/plans-thinking-approvals.md diff --git a/packages/adapters/langchain-acp/VERSION b/packages/adapters/langchain-acp/VERSION index 4cda8f1..dc1e644 100644 --- a/packages/adapters/langchain-acp/VERSION +++ b/packages/adapters/langchain-acp/VERSION @@ -1 +1 @@ -1.5.2 +1.6.0 diff --git a/packages/adapters/langchain-acp/src/langchain_acp/_version.py b/packages/adapters/langchain-acp/src/langchain_acp/_version.py index 1a19ef1..7ce04b8 100644 --- a/packages/adapters/langchain-acp/src/langchain_acp/_version.py +++ b/packages/adapters/langchain-acp/src/langchain_acp/_version.py @@ -2,4 +2,4 @@ __all__ = ("__version__",) -__version__ = "1.5.2" +__version__ = "1.6.0" diff --git a/packages/adapters/pydantic-acp/README.md b/packages/adapters/pydantic-acp/README.md index ab79bb6..f2c11f6 100644 --- a/packages/adapters/pydantic-acp/README.md +++ b/packages/adapters/pydantic-acp/README.md @@ -314,6 +314,98 @@ Current built-in bridges include: Use bridges when the runtime should gain upstream Pydantic AI capabilities and ACP-visible metadata without rewriting the adapter core. +## Protocol Extensions And Authentication + +Use `ExtensionRouter` for private or experimental ACP methods and +notifications that do not have a focused bridge. Use +`AuthenticationProvider` to contribute typed auth methods during +`initialize()` and handle `authenticate()` calls: + +```python +from acp.exceptions import RequestError +from acp.schema import AuthenticateResponse, AuthMethodAgent, ClientCapabilities +from pydantic_acp import ( + AdapterConfig, + AuthenticationMethod, + JsonValue, +) + + +class AppExtensions: + async def handle_method( + self, + method: str, + params: dict[str, JsonValue], + ) -> dict[str, JsonValue]: + if method == "acme/status": + return {"ready": True} + raise RequestError.method_not_found(method) + + async def handle_notification( + self, + method: str, + params: dict[str, JsonValue], + ) -> None: + del method, params + + +class AppAuthentication: + def get_auth_methods( + self, + client_capabilities: ClientCapabilities | None, + ) -> tuple[AuthenticationMethod, ...]: + del client_capabilities + return (AuthMethodAgent(id="app-login", name="Application login"),) + + async def authenticate(self, method_id: str) -> AuthenticateResponse: + if method_id != "app-login": + raise RequestError.invalid_params({"methodId": method_id}) + return AuthenticateResponse() + + +config = AdapterConfig( + authentication_provider=AppAuthentication(), + extension_router=AppExtensions(), +) +``` + +The default remains unchanged when these fields are omitted. Router-raised +`RequestError` values retain their ACP code and data over stdio and +`acpremote`. Terminal auth methods are advertised only when the client reports +terminal-auth support. + +Use a bridge for known Pydantic AI capability projection, an extension router +for narrow application-owned JSON-RPC messages, and a native +`acp.interfaces.Agent` when most protocol lifecycle behavior is custom. See +the [extensions and authentication guide](https://github.com/vcoderun/acpkit/blob/main/docs/pydantic-acp/extensions-and-authentication.md) +for the full ownership and error-handling contract. + +## Typed Elicitation + +Use `AcpSessionContext.ask_choice()` for a capability-gated, typed single-choice +question. The result distinguishes accepted, declined, and cancelled outcomes; +accepted results contain the original typed choice value. Unsupported clients +raise `ElicitationUnsupportedError` unless the caller supplies an explicit sync +or async fallback. + +```python +from pydantic_acp import ChoiceElicitationAccepted, ElicitationChoice + +result = await session.ask_choice( + "Choose a target", + [ + ElicitationChoice(value="preview", label="Preview", default=True), + ElicitationChoice(value="production", label="Production"), + ], +) +if isinstance(result, ChoiceElicitationAccepted): + target = result.value +``` + +The helper compiles to ACP's existing form schema and does not promise a +specific client UI. See the [typed elicitation guide](https://github.com/vcoderun/acpkit/blob/main/docs/pydantic-acp/elicitation.md) +for fallback, metadata, low-level schema, and `acpremote` behavior. + ## Harness-backed Capabilities `pydantic-acp` also ships a maintained bridge and projection layer for `pydantic-ai-harness`. @@ -431,6 +523,8 @@ Focused docs recipes: - [Pydantic ACP Overview](https://vcoderun.github.io/acpkit/pydantic-acp/) - [AdapterConfig](https://vcoderun.github.io/acpkit/pydantic-acp/adapter-config/) +- [Extensions and Authentication](https://vcoderun.github.io/acpkit/pydantic-acp/extensions-and-authentication/) +- [Typed Elicitation](https://vcoderun.github.io/acpkit/pydantic-acp/elicitation/) - [Plans, Thinking, and Approvals](https://vcoderun.github.io/acpkit/pydantic-acp/plans-thinking-approvals/) - [Models, Modes, and Slash Commands](https://vcoderun.github.io/acpkit/pydantic-acp/runtime-controls/) - [Prompt Resources and Context](https://vcoderun.github.io/acpkit/pydantic-acp/prompt-resources/) @@ -443,7 +537,7 @@ Focused docs recipes: ## Compatibility Policy -`pydantic-acp` supports `pydantic-ai-slim>=2.9.0,<=2.22.0`. Pydantic AI V1 and +`pydantic-acp` supports `pydantic-ai-slim>=2.9.0,<=2.23.0`. Pydantic AI V1 and Pydantic AI 2.x releases before 2.9.0 are outside the supported range. The ACP client provider bridge depends on the Pydantic AI v2 `Provider` and `Model` contracts. Upgrades across major Pydantic AI versions should be deliberate because the adapter exposes both server-side ACP translation and client-side ACP provider integration. @@ -468,9 +562,9 @@ agent: Agent[None, str] = Agent( The supported surface includes tool and output-tool preparation, output validation and processing hooks, deferred tool-call hooks, run metadata, -conversation IDs, and the `run_stream_events()` lifecycle used through 2.22.0. +conversation IDs, and the `run_stream_events()` lifecycle used through 2.23.0. Harness-backed filesystem, shell, and CodeMode bridges are validated against `pydantic-ai-harness[code-mode]==0.15.0` using its public capability imports. Harness 0.15.0 requires `pydantic-ai-slim>=2.22.0`; the core adapter itself -remains compatible with Pydantic AI 2.9.0 through 2.22.0. +remains compatible with Pydantic AI 2.9.0 through 2.23.0. diff --git a/packages/adapters/pydantic-acp/VERSION b/packages/adapters/pydantic-acp/VERSION index 4cda8f1..dc1e644 100644 --- a/packages/adapters/pydantic-acp/VERSION +++ b/packages/adapters/pydantic-acp/VERSION @@ -1 +1 @@ -1.5.2 +1.6.0 diff --git a/packages/adapters/pydantic-acp/pyproject.toml b/packages/adapters/pydantic-acp/pyproject.toml index 8d48519..dd5a0e1 100644 --- a/packages/adapters/pydantic-acp/pyproject.toml +++ b/packages/adapters/pydantic-acp/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ # Fixed for compliance with the ACP 0.11 public SDK. "agent-client-protocol==0.11.0", "anyio>=4.0.0", - "pydantic-ai-slim>=2.9.0,<=2.22.0", + "pydantic-ai-slim>=2.9.0,<=2.23.0", "pydantic>=2.7", "typing-extensions>=4.12.0", ] diff --git a/packages/adapters/pydantic-acp/src/pydantic_acp/__init__.py b/packages/adapters/pydantic-acp/src/pydantic_acp/__init__.py index 2ce27b0..3a09026 100644 --- a/packages/adapters/pydantic-acp/src/pydantic_acp/__init__.py +++ b/packages/adapters/pydantic-acp/src/pydantic_acp/__init__.py @@ -67,6 +67,16 @@ AcpUpdateRecord, ) from .config import AdapterConfig, PlanUpdateMode +from .elicitation import ( + ChoiceElicitationAccepted, + ChoiceElicitationCancelled, + ChoiceElicitationDeclined, + ChoiceElicitationResult, + ElicitationChoice, + ElicitationUnsupportedError, + InvalidElicitationResponseError, +) +from .extensions import AuthenticationMethod, AuthenticationProvider, ExtensionRouter from .factory import create_acp_model from .hook_projection import HookEvent, HookProjectionMap from .host import ( @@ -180,6 +190,8 @@ "ApprovalPolicy", "ApprovalPolicyStore", "ApprovalStateProvider", + "AuthenticationMethod", + "AuthenticationProvider", "AudioContentBlock", "BlackBoxHarness", "BlobResourceContents", @@ -189,13 +201,20 @@ "ClientFilesystemBackend", "ClientHostContext", "ClientTerminalBackend", + "ChoiceElicitationAccepted", + "ChoiceElicitationCancelled", + "ChoiceElicitationDeclined", + "ChoiceElicitationResult", "CompositeProjectionMap", "ConfigOption", "ConfigOptionsProvider", "DefaultPermissionToolCallBuilder", "EmbeddedResourceContentBlock", "EventEmissionMode", + "ElicitationChoice", + "ElicitationUnsupportedError", "ExternalHookEventBridge", + "ExtensionRouter", "FactoryAgentSource", "FileSessionStore", "FileSystemProjectionMap", @@ -225,6 +244,7 @@ "ImageGenerationBridge", "IncludeToolReturnSchemasBridge", "JsonValue", + "InvalidElicitationResponseError", "McpBridge", "McpCapabilityBridge", "McpServerDefinition", diff --git a/packages/adapters/pydantic-acp/src/pydantic_acp/_version.py b/packages/adapters/pydantic-acp/src/pydantic_acp/_version.py index 1a19ef1..7ce04b8 100644 --- a/packages/adapters/pydantic-acp/src/pydantic_acp/_version.py +++ b/packages/adapters/pydantic-acp/src/pydantic_acp/_version.py @@ -2,4 +2,4 @@ __all__ = ("__version__",) -__version__ = "1.5.2" +__version__ = "1.6.0" diff --git a/packages/adapters/pydantic-acp/src/pydantic_acp/config.py b/packages/adapters/pydantic-acp/src/pydantic_acp/config.py index 1085b48..33cafab 100644 --- a/packages/adapters/pydantic-acp/src/pydantic_acp/config.py +++ b/packages/adapters/pydantic-acp/src/pydantic_acp/config.py @@ -7,6 +7,7 @@ from ._version import __version__ from .approvals import ApprovalBridge, NativeApprovalBridge from .bridges import CapabilityBridge +from .extensions import AuthenticationProvider, ExtensionRouter from .hook_projection import HookProjectionMap from .host import HostAccessPolicy from .models import AdapterModel @@ -48,10 +49,12 @@ class AdapterConfig: allow_model_selection: bool = False approval_bridge: ApprovalBridge | None = field(default_factory=NativeApprovalBridge) approval_state_provider: ApprovalStateProvider | None = None + authentication_provider: AuthenticationProvider | None = None capability_bridges: Sequence[CapabilityBridge] = field(default_factory=list) config_options_provider: ConfigOptionsProvider | None = None enable_generic_tool_projection: bool = True enable_model_config_option: bool = True + extension_router: ExtensionRouter | None = None host_access_policy: HostAccessPolicy | None = None hook_projection_map: HookProjectionMap | None = field(default_factory=HookProjectionMap) models_provider: SessionModelsProvider | None = None diff --git a/packages/adapters/pydantic-acp/src/pydantic_acp/elicitation.py b/packages/adapters/pydantic-acp/src/pydantic_acp/elicitation.py new file mode 100644 index 0000000..747908e --- /dev/null +++ b/packages/adapters/pydantic-acp/src/pydantic_acp/elicitation.py @@ -0,0 +1,188 @@ +from __future__ import annotations as _annotations + +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass, field +from typing import Generic, Literal, Protocol, TypeAlias, TypeVar, assert_never + +from acp.schema import ( + AcceptElicitationResponse, + CancelElicitationResponse, + CreateElicitationResponse, + DeclineElicitationResponse, + ElicitationFormSessionMode, + ElicitationMode, + ElicitationSchema, + ElicitationStringPropertySchema, + EnumOption, +) + +from .awaitables import resolve_value + +__all__ = ( + "ChoiceElicitationAccepted", + "ChoiceElicitationCancelled", + "ChoiceElicitationDeclined", + "ChoiceElicitationResult", + "ElicitationChoice", + "ElicitationUnsupportedError", + "InvalidElicitationResponseError", +) + +ChoiceValueT = TypeVar("ChoiceValueT") + +_CHOICE_FIELD = "choice" +_CHOICE_DESCRIPTION_META_KEY = "acpkit.dev/choice-description" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ElicitationChoice(Generic[ChoiceValueT]): + """One typed value offered by :meth:`AcpSessionContext.ask_choice`.""" + + value: ChoiceValueT + label: str + description: str | None = None + default: bool = False + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ChoiceElicitationAccepted(Generic[ChoiceValueT]): + """An accepted elicitation containing the selected typed value.""" + + value: ChoiceValueT + status: Literal["accepted"] = field(init=False, default="accepted") + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ChoiceElicitationDeclined: + """An elicitation the user explicitly declined to answer.""" + + status: Literal["declined"] = field(init=False, default="declined") + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ChoiceElicitationCancelled: + """An elicitation cancelled without an answer.""" + + status: Literal["cancelled"] = field(init=False, default="cancelled") + + +ChoiceElicitationResult: TypeAlias = ( + ChoiceElicitationAccepted[ChoiceValueT] | ChoiceElicitationDeclined | ChoiceElicitationCancelled +) + + +class ElicitationUnsupportedError(RuntimeError): + """Raised when the connected ACP client cannot render the requested form.""" + + +class InvalidElicitationResponseError(RuntimeError): + """Raised when a client accepts an elicitation with an invalid selection.""" + + +class _ElicitationSession(Protocol): + session_id: str + + def supports_elicitation(self, mode: ElicitationMode) -> bool: ... + + async def create_elicitation( + self, + message: str, + mode: ElicitationMode, + ) -> CreateElicitationResponse: ... + + +async def _ask_choice( + session: _ElicitationSession, + question: str, + choices: Sequence[ElicitationChoice[ChoiceValueT]], + *, + fallback: Callable[[], ChoiceValueT | Awaitable[ChoiceValueT]] | None, +) -> ChoiceElicitationResult[ChoiceValueT]: + normalized_choices = _validate_choices(question, choices) + mode = _choice_form_mode(session.session_id, normalized_choices) + if not session.supports_elicitation(mode): + if fallback is None: + raise ElicitationUnsupportedError( + "The connected ACP client does not support form elicitation.", + ) + fallback_value = await resolve_value(fallback()) + return ChoiceElicitationAccepted(value=fallback_value) + + response = await session.create_elicitation(question, mode) + return _parse_choice_response(response, normalized_choices) + + +def _validate_choices( + question: str, + choices: Sequence[ElicitationChoice[ChoiceValueT]], +) -> tuple[ElicitationChoice[ChoiceValueT], ...]: + if not question.strip(): + raise ValueError("question must not be blank") + normalized_choices = tuple(choices) + if not normalized_choices: + raise ValueError("choices must not be empty") + if any(not choice.label.strip() for choice in normalized_choices): + raise ValueError("choice labels must not be blank") + if sum(choice.default for choice in normalized_choices) > 1: + raise ValueError("at most one choice may be the default") + return normalized_choices + + +def _choice_form_mode( + session_id: str, + choices: Sequence[ElicitationChoice[ChoiceValueT]], +) -> ElicitationFormSessionMode: + options: list[EnumOption] = [] + default_token: str | None = None + for index, choice in enumerate(choices): + token = _choice_token(index) + field_meta = ( + {_CHOICE_DESCRIPTION_META_KEY: choice.description} + if choice.description is not None + else None + ) + options.append(EnumOption(const=token, title=choice.label, field_meta=field_meta)) + if choice.default: + default_token = token + + return ElicitationFormSessionMode( + session_id=session_id, + requested_schema=ElicitationSchema( + properties={ + _CHOICE_FIELD: ElicitationStringPropertySchema( + type="string", + title="Choice", + one_of=options, + default=default_token, + ), + }, + required=[_CHOICE_FIELD], + ), + ) + + +def _parse_choice_response( + response: CreateElicitationResponse, + choices: Sequence[ElicitationChoice[ChoiceValueT]], +) -> ChoiceElicitationResult[ChoiceValueT]: + if isinstance(response, AcceptElicitationResponse): + content = response.content + token = content.get(_CHOICE_FIELD) if isinstance(content, dict) else None + if isinstance(token, str) and token.startswith("choice_"): + index_text = token.removeprefix("choice_") + if index_text.isdigit(): + index = int(index_text) + if index < len(choices) and token == _choice_token(index): + return ChoiceElicitationAccepted(value=choices[index].value) + raise InvalidElicitationResponseError( + "The ACP client accepted the elicitation without a valid choice.", + ) + if isinstance(response, DeclineElicitationResponse): + return ChoiceElicitationDeclined() + if isinstance(response, CancelElicitationResponse): + return ChoiceElicitationCancelled() + assert_never(response) + + +def _choice_token(index: int) -> str: + return f"choice_{index}" diff --git a/packages/adapters/pydantic-acp/src/pydantic_acp/extensions.py b/packages/adapters/pydantic-acp/src/pydantic_acp/extensions.py new file mode 100644 index 0000000..5ec0f2c --- /dev/null +++ b/packages/adapters/pydantic-acp/src/pydantic_acp/extensions.py @@ -0,0 +1,66 @@ +from __future__ import annotations as _annotations + +from collections.abc import Awaitable, Sequence +from typing import Protocol, TypeAlias + +from acp.schema import ( + AuthenticateResponse, + AuthMethodAgent, + ClientCapabilities, + EnvVarAuthMethod, + TerminalAuthMethod, +) + +from .session.state import JsonValue + +AuthenticationMethod: TypeAlias = EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent + +__all__ = ( + "AuthenticationMethod", + "AuthenticationProvider", + "ExtensionRouter", +) + + +class AuthenticationProvider(Protocol): + """Provide ACP authentication methods and execute authentication requests.""" + + def get_auth_methods( + self, + client_capabilities: ClientCapabilities | None, + ) -> Sequence[AuthenticationMethod] | Awaitable[Sequence[AuthenticationMethod]]: ... + + def authenticate( + self, + method_id: str, + ) -> AuthenticateResponse | None | Awaitable[AuthenticateResponse | None]: ... + + +class ExtensionRouter(Protocol): + """Handle application-owned ACP extension methods and notifications.""" + + async def handle_method( + self, + method: str, + params: dict[str, JsonValue], + ) -> dict[str, JsonValue]: ... + + async def handle_notification( + self, + method: str, + params: dict[str, JsonValue], + ) -> None: ... + + +def _filter_auth_methods_for_client( + methods: Sequence[AuthenticationMethod], + client_capabilities: ClientCapabilities | None, +) -> list[AuthenticationMethod]: + supports_terminal_auth = ( + client_capabilities is not None + and client_capabilities.auth is not None + and client_capabilities.auth.terminal is True + ) + if supports_terminal_auth: + return list(methods) + return [method for method in methods if not isinstance(method, TerminalAuthMethod)] diff --git a/packages/adapters/pydantic-acp/src/pydantic_acp/runtime/adapter.py b/packages/adapters/pydantic-acp/src/pydantic_acp/runtime/adapter.py index 8f2248b..a65729e 100644 --- a/packages/adapters/pydantic-acp/src/pydantic_acp/runtime/adapter.py +++ b/packages/adapters/pydantic-acp/src/pydantic_acp/runtime/adapter.py @@ -3,7 +3,7 @@ import asyncio from collections.abc import Sequence from dataclasses import replace -from typing import Any, ClassVar, Generic, TypeVar +from typing import Any, ClassVar, Generic, TypeVar, cast from uuid import uuid4 from acp import PROTOCOL_VERSION @@ -12,6 +12,7 @@ from acp.schema import ( AcpMcpServer, AgentCapabilities, + AuthenticateResponse, ClientCapabilities, CloseSessionResponse, ForkSessionResponse, @@ -32,8 +33,10 @@ from pydantic_ai import Agent as PydanticAgent from ..agent_source import AgentSource +from ..awaitables import resolve_value from ..bridges import PrepareToolsBridge from ..config import AdapterConfig +from ..extensions import AuthenticationMethod, _filter_auth_methods_for_client from ..models import ModelOverride from ..session.state import AcpSessionContext, JsonValue from ._adapter_mixins import ( @@ -113,6 +116,15 @@ async def initialize( del client_info, kwargs self._client_capabilities = client_capabilities negotiated_version = min(protocol_version, PROTOCOL_VERSION) + auth_methods: list[AuthenticationMethod] = [] + if self._config.authentication_provider is not None: + contributed_auth_methods = await resolve_value( + self._config.authentication_provider.get_auth_methods(client_capabilities), + ) + auth_methods = _filter_auth_methods_for_client( + contributed_auth_methods, + client_capabilities, + ) return InitializeResponse( protocol_version=negotiated_version, agent_capabilities=AgentCapabilities( @@ -131,6 +143,7 @@ async def initialize( resume=SessionResumeCapabilities(), ), ), + auth_methods=auth_methods, agent_info=Implementation( name=self._config.agent_name, title=self._config.agent_title, @@ -138,9 +151,18 @@ async def initialize( ), ) - async def authenticate(self, method_id: str, **kwargs: Any) -> None: - """Accept ACP auth handshakes when the host does not require extra auth.""" - del method_id, kwargs + async def authenticate( + self, + method_id: str, + **kwargs: Any, + ) -> AuthenticateResponse | None: + """Delegate ACP authentication or preserve the local no-op default.""" + del kwargs + if self._config.authentication_provider is None: + return None + return await resolve_value( + self._config.authentication_provider.authenticate(method_id), + ) async def fork_session( self, @@ -179,13 +201,22 @@ async def cancel(self, session_id: str, **kwargs: Any) -> None: active_task.cancel() async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]: - """Reject unsupported ACP extension methods.""" - del params - raise RequestError.method_not_found(method) + """Route configured ACP extension methods or reject unsupported methods.""" + if self._config.extension_router is None: + raise RequestError.method_not_found(method) + return await self._config.extension_router.handle_method( + method, + cast("dict[str, JsonValue]", params), + ) async def ext_notification(self, method: str, params: dict[str, Any]) -> None: - """Ignore unsupported ACP extension notifications.""" - del method, params + """Route configured ACP extension notifications or ignore them by default.""" + if self._config.extension_router is None: + return + await self._config.extension_router.handle_notification( + method, + cast("dict[str, JsonValue]", params), + ) def _native_plan_bridge( self, diff --git a/packages/adapters/pydantic-acp/src/pydantic_acp/session/state.py b/packages/adapters/pydantic-acp/src/pydantic_acp/session/state.py index d858021..3962376 100644 --- a/packages/adapters/pydantic-acp/src/pydantic_acp/session/state.py +++ b/packages/adapters/pydantic-acp/src/pydantic_acp/session/state.py @@ -1,9 +1,10 @@ from __future__ import annotations as _annotations +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path -from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, assert_never +from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, assert_never from acp.exceptions import RequestError from acp.schema import ( @@ -23,6 +24,12 @@ from pydantic import BaseModel from typing_extensions import TypeIs +from ..elicitation import ( + ChoiceElicitationResult, + ElicitationChoice, + _ask_choice, +) + if TYPE_CHECKING: from acp.interfaces import Client as AcpClient @@ -48,6 +55,7 @@ } _TRANSCRIPT_KINDS: Final = frozenset(_SESSION_UPDATE_MODELS) +ChoiceValueT = TypeVar("ChoiceValueT") def _is_transcript_kind(value: JsonValue) -> TypeIs[SessionTranscriptKind]: @@ -152,6 +160,20 @@ async def create_elicitation( raise RequestError.invalid_request({"reason": "client_not_connected"}) return await self.client.create_elicitation(message=message, mode=mode) + async def ask_choice( + self, + question: str, + choices: Sequence[ElicitationChoice[ChoiceValueT]], + *, + fallback: Callable[[], ChoiceValueT | Awaitable[ChoiceValueT]] | None = None, + ) -> ChoiceElicitationResult[ChoiceValueT]: + """Ask the connected client to select one typed value. + + The helper compiles choices to the standard ACP form-elicitation schema. + Clients control presentation and may ignore option descriptions. + """ + return await _ask_choice(self, question, choices, fallback=fallback) + async def complete_elicitation(self, elicitation_id: str) -> None: if self.client is None: raise RequestError.invalid_request({"reason": "client_not_connected"}) diff --git a/packages/helpers/codex-auth-helper/VERSION b/packages/helpers/codex-auth-helper/VERSION index 4cda8f1..dc1e644 100644 --- a/packages/helpers/codex-auth-helper/VERSION +++ b/packages/helpers/codex-auth-helper/VERSION @@ -1 +1 @@ -1.5.2 +1.6.0 diff --git a/packages/helpers/codex-auth-helper/src/codex_auth_helper/_version.py b/packages/helpers/codex-auth-helper/src/codex_auth_helper/_version.py index 1a19ef1..7ce04b8 100644 --- a/packages/helpers/codex-auth-helper/src/codex_auth_helper/_version.py +++ b/packages/helpers/codex-auth-helper/src/codex_auth_helper/_version.py @@ -2,4 +2,4 @@ __all__ = ("__version__",) -__version__ = "1.5.2" +__version__ = "1.6.0" diff --git a/packages/transports/acpremote/README.md b/packages/transports/acpremote/README.md index 2dc8068..8c46316 100644 --- a/packages/transports/acpremote/README.md +++ b/packages/transports/acpremote/README.md @@ -188,6 +188,11 @@ is set explicitly. ## Transport Timing +ACP 0.11 elicitation routes require an explicit SDK opt-in. Set +`TransportOptions(use_unstable_protocol=True)` on object-based server and +client connections that need elicitation. `serve_command()` remains a raw +frame relay; its spawned command must enable unstable routes itself. + `TransportOptions` can attach proxy-observed latency information to the ACP stream: ```python diff --git a/packages/transports/acpremote/VERSION b/packages/transports/acpremote/VERSION index 4cda8f1..dc1e644 100644 --- a/packages/transports/acpremote/VERSION +++ b/packages/transports/acpremote/VERSION @@ -1 +1 @@ -1.5.2 +1.6.0 diff --git a/packages/transports/acpremote/src/acpremote/_version.py b/packages/transports/acpremote/src/acpremote/_version.py index 1a19ef1..7ce04b8 100644 --- a/packages/transports/acpremote/src/acpremote/_version.py +++ b/packages/transports/acpremote/src/acpremote/_version.py @@ -2,4 +2,4 @@ __all__ = ("__version__",) -__version__ = "1.5.2" +__version__ = "1.6.0" diff --git a/packages/transports/acpremote/src/acpremote/client.py b/packages/transports/acpremote/src/acpremote/client.py index aa049f8..6a005a5 100644 --- a/packages/transports/acpremote/src/acpremote/client.py +++ b/packages/transports/acpremote/src/acpremote/client.py @@ -67,7 +67,12 @@ async def connect_remote_agent( websocket, reader_limit=resolved_options.reader_limit, ) - connection = connect_to_agent(client, streams.writer, streams.reader) + connection = connect_to_agent( + client, + streams.writer, + streams.reader, + use_unstable_protocol=resolved_options.use_unstable_protocol, + ) metadata = await fetch_server_metadata( url, headers=resolved_headers, diff --git a/packages/transports/acpremote/src/acpremote/config.py b/packages/transports/acpremote/src/acpremote/config.py index 11e410c..4c0aec4 100644 --- a/packages/transports/acpremote/src/acpremote/config.py +++ b/packages/transports/acpremote/src/acpremote/config.py @@ -37,6 +37,7 @@ class TransportOptions: host_ownership: Literal["remote", "client_passthrough"] = "remote" emit_latency_meta: bool = False emit_latency_projection: bool = False + use_unstable_protocol: bool = False @dataclass(frozen=True, kw_only=True) diff --git a/packages/transports/acpremote/src/acpremote/server.py b/packages/transports/acpremote/src/acpremote/server.py index 16b9655..17cf253 100644 --- a/packages/transports/acpremote/src/acpremote/server.py +++ b/packages/transports/acpremote/src/acpremote/server.py @@ -39,7 +39,12 @@ async def run_remote_agent_connection( reader_limit=resolved_options.reader_limit, ) try: - await run_agent(agent, input_stream=bridge.writer, output_stream=bridge.reader) + await run_agent( + agent, + input_stream=bridge.writer, + output_stream=bridge.reader, + use_unstable_protocol=resolved_options.use_unstable_protocol, + ) finally: await bridge.close() diff --git a/pyproject.toml b/pyproject.toml index a1c41db..5e514ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,17 +18,17 @@ keywords = ["acp", "agents", "protocol", "pydantic-ai"] dependencies = [ "click>=8.1.8", "pre-commit>=4.6.0", - "pydantic-ai>=2.9.0,<=2.22.0", + "pydantic-ai>=2.9.0,<=2.23.0", "typing-extensions>=4.12.0", ] [project.optional-dependencies] -codex = ["codex-auth-helper>=1.5.2,<2.0.0"] -deepagents = ["langchain-acp[deepagents]>=1.5.2,<2.0.0"] -langchain = ["langchain-acp>=1.5.2,<2.0.0"] +codex = ["codex-auth-helper>=1.6.0,<2.0.0"] +deepagents = ["langchain-acp[deepagents]>=1.6.0,<2.0.0"] +langchain = ["langchain-acp>=1.6.0,<2.0.0"] launch = ["uv>=0.8.3"] -pydantic = ["pydantic-acp>=1.5.2,<2.0.0"] -remote = ["acpremote>=1.5.2,<2.0.0"] +pydantic = ["pydantic-acp>=1.6.0,<2.0.0"] +remote = ["acpremote>=1.6.0,<2.0.0"] dev = [ "acpremote", @@ -40,7 +40,7 @@ dev = [ "python-dotenv", "pydantic-acp", "pydantic-ai-harness[code-mode]==0.15.0", - "pydantic-ai-slim>=2.9.0,<=2.22.0", + "pydantic-ai-slim>=2.9.0,<=2.23.0", "pydantic-graph", "pydantic>=2.7", "pytest-asyncio", diff --git a/scripts/generate_llms_docs.py b/scripts/generate_llms_docs.py index 0ac3fa5..5788188 100644 --- a/scripts/generate_llms_docs.py +++ b/scripts/generate_llms_docs.py @@ -79,6 +79,18 @@ def url(self) -> str: summary="Field-by-field guide to runtime configuration, prompt capabilities, ownership, and adapter behavior.", path="docs/pydantic-acp/adapter-config.md", ), + DocPage( + section="Core Docs", + title="Pydantic ACP Extensions and Authentication", + summary="Typed extension routing, authentication providers, structured ACP errors, and bridge-versus-native ownership guidance.", + path="docs/pydantic-acp/extensions-and-authentication.md", + ), + DocPage( + section="Core Docs", + title="Pydantic ACP Typed Elicitation", + summary="Typed single-choice elicitation, capability negotiation, discriminated outcomes, explicit fallbacks, and remote transport requirements.", + path="docs/pydantic-acp/elicitation.md", + ), DocPage( section="Core Docs", title="Session State and Lifecycle", diff --git a/src/acpkit/_version.py b/src/acpkit/_version.py index 1a19ef1..7ce04b8 100644 --- a/src/acpkit/_version.py +++ b/src/acpkit/_version.py @@ -2,4 +2,4 @@ __all__ = ("__version__",) -__version__ = "1.5.2" +__version__ = "1.6.0" diff --git a/tests/acpremote/test_command_server.py b/tests/acpremote/test_command_server.py index 5882c2f..a7c2875 100644 --- a/tests/acpremote/test_command_server.py +++ b/tests/acpremote/test_command_server.py @@ -7,9 +7,17 @@ import pytest from acp import text_block +from acp.exceptions import RequestError from acp.interfaces import Client from acp.schema import ( + AcceptElicitationResponse, AgentMessageChunk, + AuthCapabilities, + ClientCapabilities, + CreateElicitationResponse, + ElicitationCapabilities, + ElicitationFormCapabilities, + ElicitationMode, PermissionOption, RequestPermissionResponse, SessionNotification, @@ -17,6 +25,7 @@ ) from acpremote import ( CommandOptions, + TransportOptions, connect_remote_agent, serve_command, serve_stdio_command, @@ -27,6 +36,8 @@ @dataclass(slots=True) class _RecordingClient: updates: list[SessionNotification] = field(default_factory=list) + elicitation_responses: list[CreateElicitationResponse] = field(default_factory=list) + elicitation_calls: list[tuple[str, ElicitationMode]] = field(default_factory=list) async def request_permission( self, @@ -43,6 +54,18 @@ async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> N SessionNotification(session_id=session_id, update=update, field_meta=kwargs or None), ) + async def create_elicitation( + self, + message: str, + mode: ElicitationMode, + **kwargs: Any, + ) -> CreateElicitationResponse: + del kwargs + self.elicitation_calls.append((message, mode)) + if not self.elicitation_responses: + raise AssertionError("unexpected elicitation request") + return self.elicitation_responses.pop(0) + async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]: del method, params raise AssertionError("extension methods are not used by command relay tests") @@ -62,6 +85,8 @@ async def test_command_server_recording_client_stub_methods() -> None: await client.request_permission([], "session-1", cast("Any", object())) with pytest.raises(AssertionError, match="extension methods"): await client.ext_method("demo.echo", {"value": 1}) + with pytest.raises(AssertionError, match="unexpected elicitation"): + await client.create_elicitation("Choose", cast("Any", object())) await client.ext_notification("demo.note", {"value": 2}) await client.session_update( @@ -150,6 +175,84 @@ def _write_stdio_acp_script(tmp_path: Path, *, emit_stderr: bool = False) -> Pat return script_path +def _write_pydantic_extension_script(tmp_path: Path) -> Path: + script_path = tmp_path / "pydantic_extension_agent.py" + script_path.write_text( + "\n".join( + ( + "from __future__ import annotations", + "", + "import asyncio", + "", + "from acp import run_agent", + "from acp.exceptions import RequestError", + "from acp.schema import AuthenticateResponse, AuthMethodAgent, AvailableCommand, ClientCapabilities", + "from pydantic_ai import Agent", + "from pydantic_ai.models.test import TestModel", + "from pydantic_acp import AdapterConfig, ChoiceElicitationAccepted, ElicitationChoice, JsonValue, SlashCommandResult, StaticSlashCommand, StaticSlashCommandProvider, create_acp_agent", + "", + "class Router:", + " def __init__(self) -> None:", + " self.notifications: list[str] = []", + "", + " async def handle_method(self, method: str, params: dict[str, JsonValue]) -> dict[str, JsonValue]:", + " if method == 'demo.echo':", + " return {'echo': params.get('value')}", + " if method == 'demo.state':", + " return {'notifications': list(self.notifications)}", + " raise RequestError.invalid_params({'method': method})", + "", + " async def handle_notification(self, method: str, params: dict[str, JsonValue]) -> None:", + " del params", + " self.notifications.append(method)", + "", + "class AuthProvider:", + " def get_auth_methods(self, client_capabilities: ClientCapabilities | None) -> tuple[AuthMethodAgent, ...]:", + " del client_capabilities", + " return (AuthMethodAgent(id='agent-login', name='Agent login'),)", + "", + " def authenticate(self, method_id: str) -> AuthenticateResponse:", + " if method_id != 'agent-login':", + " raise RequestError.invalid_params({'methodId': method_id})", + " return AuthenticateResponse(_meta={'authenticated': True})", + "", + "router = Router()", + "async def choose_target(request):", + " result = await request.session.ask_choice(", + " 'Choose a deployment target',", + " [", + " ElicitationChoice(value='preview', label='Preview'),", + " ElicitationChoice(value='production', label='Production', default=True),", + " ],", + " )", + " if isinstance(result, ChoiceElicitationAccepted):", + " return SlashCommandResult(text=f'selected:{result.value}')", + " return SlashCommandResult(text=result.status)", + "", + "adapter = create_acp_agent(", + " agent=Agent(TestModel()),", + " config=AdapterConfig(", + " authentication_provider=AuthProvider(),", + " extension_router=router,", + " slash_command_provider=StaticSlashCommandProvider(", + " commands=[", + " StaticSlashCommand(", + " command=AvailableCommand(name='choose', description='Choose a target.'),", + " handler=choose_target,", + " ),", + " ],", + " ),", + " ),", + ")", + "asyncio.run(run_agent(adapter, use_unstable_protocol=True))", + ), + ) + + "\n", + encoding="utf-8", + ) + return script_path + + def test_command_options_reject_empty_command() -> None: with pytest.raises(ValueError, match="command must not be empty"): CommandOptions(command=()) @@ -209,6 +312,79 @@ async def test_serve_command_relays_stdio_acp_process(tmp_path: Path) -> None: assert client.updates[0].update.content.text == f"relay:{tmp_path}:hello from ws" +@pytest.mark.asyncio +async def test_serve_command_forwards_pydantic_extension_and_auth_strategies( + tmp_path: Path, +) -> None: + script_path = _write_pydantic_extension_script(tmp_path) + server = await serve_command( + [sys.executable, str(script_path)], + mount_path="/extensions", + cwd=str(tmp_path), + ) + assert server.sockets is not None + port = next(iter(server.sockets)).getsockname()[1] + client = _RecordingClient( + elicitation_responses=[ + AcceptElicitationResponse(action="accept", content={"choice": "choice_1"}), + ], + ) + remote = await connect_remote_agent( + cast("Client", client), + f"ws://127.0.0.1:{port}/extensions/ws", + options=TransportOptions(use_unstable_protocol=True), + ) + try: + initialized = await remote.connection.initialize( + protocol_version=1, + client_capabilities=ClientCapabilities( + auth=AuthCapabilities(terminal=False), + elicitation=ElicitationCapabilities(form=ElicitationFormCapabilities()), + ), + ) + assert initialized.auth_methods is not None + assert [method.id for method in initialized.auth_methods] == ["agent-login"] + + auth_response = await remote.connection.authenticate(method_id="agent-login") + assert auth_response is not None + assert auth_response.field_meta == {"authenticated": True} + + assert await remote.connection.ext_method( + method="demo.echo", + params={"value": "through-stdio-and-ws"}, + ) == {"echo": "through-stdio-and-ws"} + await remote.connection.ext_notification( + method="demo.changed", + params={"revision": 1}, + ) + assert await remote.connection.ext_method(method="demo.state", params={}) == { + "notifications": ["demo.changed"], + } + + session = await remote.connection.new_session(cwd=str(tmp_path)) + prompt_response = await remote.connection.prompt( + prompt=[text_block("/choose")], + session_id=session.session_id, + ) + assert prompt_response.stop_reason == "end_turn" + assert len(client.elicitation_calls) == 1 + assert client.elicitation_calls[0][0] == "Choose a deployment target" + assert any( + isinstance(notification.update, AgentMessageChunk) + and notification.update.content.text == "selected:production" + for notification in client.updates + ) + + with pytest.raises(RequestError) as exc_info: + await remote.connection.ext_method(method="demo.invalid", params={}) + assert exc_info.value.code == RequestError.invalid_params().code + assert exc_info.value.data == {"method": "demo.invalid"} + finally: + await remote.close() + server.close() + await server.wait_closed() + + @pytest.mark.asyncio async def test_serve_command_metadata_exposes_remote_cwd(tmp_path: Path) -> None: script_path = _write_stdio_acp_script(tmp_path) diff --git a/tests/acpremote/test_phase1.py b/tests/acpremote/test_phase1.py index 85b83e4..225b941 100644 --- a/tests/acpremote/test_phase1.py +++ b/tests/acpremote/test_phase1.py @@ -286,6 +286,7 @@ def test_phase1_helpers_cover_header_merge_and_transport_defaults() -> None: assert options.reader_limit == 1_048_576 assert options.max_size == 1_048_576 assert options.max_queue == 16 + assert options.use_unstable_protocol is False assert TransportMetadata().transport_kind == "websocket" assert TransportMetadata().transport_version == 1 diff --git a/tests/pydantic/test_acp_client_provider.py b/tests/pydantic/test_acp_client_provider.py index b1f5cae..a418eab 100644 --- a/tests/pydantic/test_acp_client_provider.py +++ b/tests/pydantic/test_acp_client_provider.py @@ -317,7 +317,7 @@ def test_pydantic_acp_requires_pydantic_ai_v2() -> None: ) assert ">=2.9.0" in pydantic_ai_dependency - assert "<=2.22.0" in pydantic_ai_dependency + assert "<=2.23.0" in pydantic_ai_dependency assert "==1." not in pydantic_ai_dependency @@ -2160,7 +2160,7 @@ def test_root_pyproject_declares_pydantic_ai_v2_dependency() -> None: data: dict[str, Any] = tomllib.loads(root_pyproject.read_text()) dependencies: list[str] = data["project"]["dependencies"] - assert any(dependency.startswith("pydantic-ai>=2.9.0,<=2.22.0") for dependency in dependencies) + assert any(dependency.startswith("pydantic-ai>=2.9.0,<=2.23.0") for dependency in dependencies) def test_pydantic_acp_pins_agent_client_protocol_version_used_by_client_module() -> None: diff --git a/tests/pydantic/test_elicitation.py b/tests/pydantic/test_elicitation.py new file mode 100644 index 0000000..884fa78 --- /dev/null +++ b/tests/pydantic/test_elicitation.py @@ -0,0 +1,256 @@ +from __future__ import annotations as _annotations + +import asyncio +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, cast + +import pytest +from acp.interfaces import Client as AcpClient +from acp.schema import ( + AcceptElicitationResponse, + CancelElicitationResponse, + ClientCapabilities, + CreateElicitationResponse, + DeclineElicitationResponse, + ElicitationCapabilities, + ElicitationFormCapabilities, + ElicitationFormSessionMode, + ElicitationMode, + ElicitationStringPropertySchema, +) +from pydantic_acp import ( + AcpSessionContext, + ChoiceElicitationAccepted, + ChoiceElicitationCancelled, + ChoiceElicitationDeclined, + ElicitationChoice, + ElicitationUnsupportedError, + InvalidElicitationResponseError, +) +from pydantic_acp.elicitation import _parse_choice_response + + +@dataclass(slots=True) +class _ElicitationClient: + responses: list[CreateElicitationResponse] = field(default_factory=list) + calls: list[tuple[str, ElicitationMode]] = field(default_factory=list) + + async def create_elicitation( + self, + message: str, + mode: ElicitationMode, + **kwargs: Any, + ) -> CreateElicitationResponse: + del kwargs + self.calls.append((message, mode)) + if not self.responses: + raise AssertionError("unexpected elicitation request") + return self.responses.pop(0) + + +def _session( + client: _ElicitationClient, + *, + supports_form: bool = True, +) -> AcpSessionContext: + capabilities = ( + ClientCapabilities( + elicitation=ElicitationCapabilities(form=ElicitationFormCapabilities()), + ) + if supports_form + else ClientCapabilities() + ) + now = datetime.now(UTC) + return AcpSessionContext( + session_id="elicitation-session", + cwd=Path("/tmp"), + created_at=now, + updated_at=now, + client=cast("AcpClient", client), + client_capabilities=capabilities, + ) + + +def test_ask_choice_returns_typed_value_and_compiles_form_schema() -> None: + client = _ElicitationClient( + responses=[ + AcceptElicitationResponse( + action="accept", + content={"choice": "choice_1"}, + ), + ], + ) + session = _session(client) + + result = asyncio.run( + session.ask_choice( + "Choose a deployment target", + [ + ElicitationChoice(value=10, label="Preview"), + ElicitationChoice( + value=20, + label="Production", + description="Deploy to the production environment.", + default=True, + ), + ], + ), + ) + + assert result == ChoiceElicitationAccepted(value=20) + assert result.status == "accepted" + assert len(client.calls) == 1 + message, mode = client.calls[0] + assert message == "Choose a deployment target" + assert isinstance(mode, ElicitationFormSessionMode) + assert mode.session_id == "elicitation-session" + assert mode.requested_schema.required == ["choice"] + properties = mode.requested_schema.properties + assert properties is not None + choice_schema = properties["choice"] + assert isinstance(choice_schema, ElicitationStringPropertySchema) + assert choice_schema.default == "choice_1" + assert choice_schema.one_of is not None + assert [(option.const, option.title) for option in choice_schema.one_of] == [ + ("choice_0", "Preview"), + ("choice_1", "Production"), + ] + assert choice_schema.one_of[0].field_meta is None + assert choice_schema.one_of[1].field_meta == { + "acpkit.dev/choice-description": "Deploy to the production environment.", + } + + +@pytest.mark.parametrize( + ("response", "expected"), + [ + ( + DeclineElicitationResponse(action="decline"), + ChoiceElicitationDeclined(), + ), + ( + CancelElicitationResponse(action="cancel"), + ChoiceElicitationCancelled(), + ), + ], +) +def test_ask_choice_preserves_non_accepted_outcomes( + response: CreateElicitationResponse, + expected: ChoiceElicitationDeclined | ChoiceElicitationCancelled, +) -> None: + client = _ElicitationClient(responses=[response]) + + result = asyncio.run( + _session(client).ask_choice( + "Continue?", + [ElicitationChoice(value=True, label="Continue")], + ), + ) + + assert result == expected + assert result.status in {"declined", "cancelled"} + + +def test_ask_choice_rejects_unsupported_client_without_permission_fallback() -> None: + client = _ElicitationClient() + + with pytest.raises(ElicitationUnsupportedError, match="does not support form"): + asyncio.run( + _session(client, supports_form=False).ask_choice( + "Continue?", + [ElicitationChoice(value=True, label="Continue")], + ), + ) + + assert client.calls == [] + with pytest.raises(AssertionError, match="unexpected elicitation"): + asyncio.run(client.create_elicitation("unused", cast("Any", object()))) + + +def test_ask_choice_uses_explicit_async_fallback_for_unsupported_client() -> None: + client = _ElicitationClient() + + async def fallback() -> int: + await asyncio.sleep(0) + return 20 + + result = asyncio.run( + _session(client, supports_form=False).ask_choice( + "Choose a deployment target", + [ + ElicitationChoice(value=10, label="Preview"), + ElicitationChoice(value=20, label="Production", default=True), + ], + fallback=fallback, + ), + ) + + assert result == ChoiceElicitationAccepted(value=20) + assert client.calls == [] + + +@pytest.mark.parametrize( + "content", + [ + None, + {}, + {"choice": 1}, + {"choice": "unknown"}, + {"choice": "choice_nope"}, + {"choice": "choice_9"}, + {"choice": "choice_01"}, + ], +) +def test_ask_choice_rejects_invalid_accepted_response( + content: dict[str, Any] | None, +) -> None: + client = _ElicitationClient( + responses=[AcceptElicitationResponse(action="accept", content=content)], + ) + + with pytest.raises(InvalidElicitationResponseError, match="valid choice"): + asyncio.run( + _session(client).ask_choice( + "Continue?", + [ElicitationChoice(value=True, label="Continue")], + ), + ) + + +@pytest.mark.parametrize( + ("question", "choices", "message"), + [ + (" ", [ElicitationChoice(value=1, label="One")], "question must not be blank"), + ("Choose", [], "choices must not be empty"), + ( + "Choose", + [ElicitationChoice(value=1, label=" ")], + "choice labels must not be blank", + ), + ( + "Choose", + [ + ElicitationChoice(value=1, label="One", default=True), + ElicitationChoice(value=2, label="Two", default=True), + ], + "at most one choice may be the default", + ), + ], +) +def test_ask_choice_validates_question_and_choices( + question: str, + choices: list[ElicitationChoice[int]], + message: str, +) -> None: + with pytest.raises(ValueError, match=message): + asyncio.run(_session(_ElicitationClient()).ask_choice(question, choices)) + + +def test_choice_response_parser_rejects_unknown_response_variant() -> None: + with pytest.raises(AssertionError): + _parse_choice_response( + cast("Any", object()), + [ElicitationChoice(value=1, label="One")], + ) diff --git a/tests/pydantic/test_extensions.py b/tests/pydantic/test_extensions.py new file mode 100644 index 0000000..f700d6d --- /dev/null +++ b/tests/pydantic/test_extensions.py @@ -0,0 +1,142 @@ +from __future__ import annotations as _annotations + +import asyncio +from dataclasses import dataclass, field + +import pytest +from acp.exceptions import RequestError +from acp.schema import ( + AuthCapabilities, + AuthenticateResponse, + AuthMethodAgent, + ClientCapabilities, + TerminalAuthMethod, +) +from pydantic_acp import AdapterConfig, AuthenticationMethod, JsonValue, create_acp_agent +from pydantic_ai import Agent +from pydantic_ai.models.test import TestModel + + +@dataclass(slots=True) +class _RecordingExtensionRouter: + notifications: list[tuple[str, dict[str, JsonValue]]] = field(default_factory=list) + + async def handle_method( + self, + method: str, + params: dict[str, JsonValue], + ) -> dict[str, JsonValue]: + if method == "demo.echo": + return {"echo": params.get("value")} + raise RequestError.invalid_params({"method": method}) + + async def handle_notification( + self, + method: str, + params: dict[str, JsonValue], + ) -> None: + self.notifications.append((method, params)) + + +@dataclass(slots=True) +class _RecordingAuthenticationProvider: + capabilities: list[ClientCapabilities | None] = field(default_factory=list) + authenticated_method_ids: list[str] = field(default_factory=list) + + def get_auth_methods( + self, + client_capabilities: ClientCapabilities | None, + ) -> tuple[AuthenticationMethod, ...]: + self.capabilities.append(client_capabilities) + return ( + AuthMethodAgent(id="agent-login", name="Agent login"), + TerminalAuthMethod(id="terminal-login", name="Terminal login", type="terminal"), + ) + + async def authenticate(self, method_id: str) -> AuthenticateResponse: + if method_id != "agent-login": + raise RequestError.invalid_params({"methodId": method_id}) + self.authenticated_method_ids.append(method_id) + return AuthenticateResponse(field_meta={"methodId": method_id}) + + +class _AsyncMethodsSyncAuthenticationProvider: + async def get_auth_methods( + self, + client_capabilities: ClientCapabilities | None, + ) -> tuple[AuthenticationMethod, ...]: + del client_capabilities + return (AuthMethodAgent(id="async-methods", name="Async methods"),) + + def authenticate(self, method_id: str) -> None: + assert method_id == "async-methods" + + +def test_extension_router_handles_methods_notifications_and_structured_errors() -> None: + router = _RecordingExtensionRouter() + adapter = create_acp_agent( + agent=Agent(TestModel()), + config=AdapterConfig(extension_router=router), + ) + + result = asyncio.run(adapter.ext_method("demo.echo", {"value": [1, "two"]})) + asyncio.run(adapter.ext_notification("demo.changed", {"revision": 3})) + + assert result == {"echo": [1, "two"]} + assert router.notifications == [("demo.changed", {"revision": 3})] + + with pytest.raises(RequestError) as exc_info: + asyncio.run(adapter.ext_method("demo.invalid", {})) + + assert exc_info.value.code == RequestError.invalid_params().code + assert exc_info.value.data == {"method": "demo.invalid"} + + +def test_authentication_provider_advertises_supported_methods_and_authenticates() -> None: + provider = _RecordingAuthenticationProvider() + adapter = create_acp_agent( + agent=Agent(TestModel()), + config=AdapterConfig(authentication_provider=provider), + ) + + default_response = asyncio.run(adapter.initialize(protocol_version=1)) + terminal_response = asyncio.run( + adapter.initialize( + protocol_version=1, + client_capabilities=ClientCapabilities(auth=AuthCapabilities(terminal=True)), + ), + ) + auth_response = asyncio.run(adapter.authenticate("agent-login")) + + assert default_response.auth_methods is not None + assert [method.id for method in default_response.auth_methods] == ["agent-login"] + assert terminal_response.auth_methods is not None + assert [method.id for method in terminal_response.auth_methods] == [ + "agent-login", + "terminal-login", + ] + assert provider.capabilities == [None, ClientCapabilities(auth=AuthCapabilities(terminal=True))] + assert provider.authenticated_method_ids == ["agent-login"] + assert auth_response is not None + assert auth_response.field_meta == {"methodId": "agent-login"} + + with pytest.raises(RequestError) as exc_info: + asyncio.run(adapter.authenticate("missing")) + + assert exc_info.value.code == RequestError.invalid_params().code + assert exc_info.value.data == {"methodId": "missing"} + + +def test_authentication_provider_accepts_async_discovery_and_sync_authentication() -> None: + adapter = create_acp_agent( + agent=Agent(TestModel()), + config=AdapterConfig( + authentication_provider=_AsyncMethodsSyncAuthenticationProvider(), + ), + ) + + response = asyncio.run(adapter.initialize(protocol_version=1)) + + assert response.auth_methods is not None + assert [method.id for method in response.auth_methods] == ["async-methods"] + assert asyncio.run(adapter.authenticate("async-methods")) is None diff --git a/tests/pydantic/test_pydantic_ai_compat.py b/tests/pydantic/test_pydantic_ai_compat.py index 1df7433..860cdf8 100644 --- a/tests/pydantic/test_pydantic_ai_compat.py +++ b/tests/pydantic/test_pydantic_ai_compat.py @@ -22,7 +22,7 @@ WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" SUPPORTED_FLOOR = Version("2.9.0") -SUPPORTED_CEILING = Version("2.22.0") +SUPPORTED_CEILING = Version("2.23.0") EXPECTED_MATRIX_VERSIONS = ( "2.9.0", "2.9.1", @@ -40,6 +40,7 @@ "2.20.0", "2.21.0", "2.22.0", + "2.23.0", ) HARNESS_PIN = "pydantic-ai-harness[code-mode]==0.15.0" @@ -100,7 +101,7 @@ def test_pydantic_acp_declares_supported_pydantic_ai_range() -> None: assert requirement.specifier.contains("2.9.0") assert requirement.specifier.contains(str(SUPPORTED_CEILING)) assert not requirement.specifier.contains("2.8.0") - assert not requirement.specifier.contains("2.23.0") + assert not requirement.specifier.contains("2.24.0") def test_root_dev_extra_pins_supported_pydantic_ai_and_harness() -> None: @@ -157,6 +158,7 @@ def test_pydantic_ai_matrix_covers_each_supported_minor_endpoint() -> None: Version("2.20.0"), Version("2.21.0"), Version("2.22.0"), + Version("2.23.0"), } assert matrix_versions == expected_minors diff --git a/tests/test_release.py b/tests/test_release.py index d86102f..21f7923 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -24,6 +24,7 @@ Path("packages/helpers/codex-auth-helper/VERSION"), Path("packages/transports/acpremote/VERSION"), ) +_PUBLISH_WORKFLOW = _ROOT / ".github" / "workflows" / "publish.yml" def _run_release(*args: str) -> subprocess.CompletedProcess[str]: @@ -64,6 +65,16 @@ def test_release_metadata_rejects_invalid_release_date() -> None: assert "does not match workspace version" in result.stderr +def test_publish_workflow_requires_a_published_github_release() -> None: + workflow = _PUBLISH_WORKFLOW.read_text(encoding="utf-8") + + assert "release:\n types: [published]" in workflow + assert "push:\n tags:" not in workflow + assert "RELEASE_TAG: ${{ github.event.release.tag_name }}" in workflow + assert 'make release RELEASE_TAG="$RELEASE_TAG"' in workflow + assert "github.event.release.tag_name" in workflow + + def test_bump_script_updates_version_files_and_root_extras(tmp_path: Path) -> None: for relative_path in (*_BUMP_VERSION_FILES, Path("pyproject.toml")): source = _ROOT / relative_path diff --git a/uv.lock b/uv.lock index bf40ada..dbf3f5c 100644 --- a/uv.lock +++ b/uv.lock @@ -140,11 +140,11 @@ requires-dist = [ { name = "pydantic-acp", marker = "extra == 'dev'", editable = "packages/adapters/pydantic-acp" }, { name = "pydantic-acp", marker = "extra == 'dev-all'", editable = "packages/adapters/pydantic-acp" }, { name = "pydantic-acp", marker = "extra == 'pydantic'", editable = "packages/adapters/pydantic-acp" }, - { name = "pydantic-ai", specifier = ">=2.9.0,<=2.22.0" }, + { name = "pydantic-ai", specifier = ">=2.9.0,<=2.23.0" }, { name = "pydantic-ai-harness", extras = ["code-mode"], marker = "extra == 'dev'", specifier = "==0.15.0" }, { name = "pydantic-ai-harness", extras = ["code-mode"], marker = "extra == 'dev-all'", specifier = "==0.15.0" }, - { name = "pydantic-ai-slim", marker = "extra == 'dev'", specifier = ">=2.9.0,<=2.22.0" }, - { name = "pydantic-ai-slim", marker = "extra == 'dev-all'", specifier = ">=2.9.0,<=2.22.0" }, + { name = "pydantic-ai-slim", marker = "extra == 'dev'", specifier = ">=2.9.0,<=2.23.0" }, + { name = "pydantic-ai-slim", marker = "extra == 'dev-all'", specifier = ">=2.9.0,<=2.23.0" }, { name = "pydantic-graph", marker = "extra == 'dev'" }, { name = "pydantic-graph", marker = "extra == 'dev-all'" }, { name = "pytest", marker = "extra == 'dev'" }, @@ -2280,21 +2280,21 @@ requires-dist = [ { name = "anyio", specifier = ">=4.0.0" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-ai-harness", extras = ["code-mode"], marker = "extra == 'harness'", specifier = "==0.15.0" }, - { name = "pydantic-ai-slim", specifier = ">=2.9.0,<=2.22.0" }, + { name = "pydantic-ai-slim", specifier = ">=2.9.0,<=2.23.0" }, { name = "typing-extensions", specifier = ">=4.12.0" }, ] provides-extras = ["harness"] [[package]] name = "pydantic-ai" -version = "2.22.0" +version = "2.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic-ai-slim", extra = ["anthropic", "cli", "evals", "google", "logfire", "mcp", "openai", "retries", "web"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/d8/089e10432b470a4f8cc64589df57113c15fca648ddf3b37aaf2ec13d8e81/pydantic_ai-2.22.0.tar.gz", hash = "sha256:4581833f40acc36795b7eebf7bdae79257f448c70cccd0d43eed7ba7a6beb266", size = 19316, upload-time = "2026-08-01T02:38:20.709Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/73/8dbd43b74f31c187a57fc2b7ae35d2596893b8f386abebadc2d136e62e7e/pydantic_ai-2.23.0.tar.gz", hash = "sha256:3da15a28e171cbb4548f3fffbd098dd9df44888c800dd7e48633795e18525a07", size = 19369, upload-time = "2026-08-04T01:58:18.18Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/de/78ee728460b4fba487f5ef778a6e7565522c02f39c1a995810eada3a9416/pydantic_ai-2.22.0-py3-none-any.whl", hash = "sha256:3e54f4e973908147e53eaf87bf20c40fb7c2867cf878b70114d3f29244471a94", size = 7741, upload-time = "2026-08-01T02:38:13.199Z" }, + { url = "https://files.pythonhosted.org/packages/3d/af/965fb83595ab34f5c0b6363323ac15c9aa478fc5de7cfc3360386eed7bd0/pydantic_ai-2.23.0-py3-none-any.whl", hash = "sha256:a9042f5880522565c36e716a983c196d57cc9e2c40e8fd1188ee40802fc8d104", size = 7740, upload-time = "2026-08-04T01:58:08.868Z" }, ] [[package]] @@ -2318,7 +2318,7 @@ code-mode = [ [[package]] name = "pydantic-ai-slim" -version = "2.22.0" +version = "2.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2330,9 +2330,9 @@ dependencies = [ { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/aa/af0c3f1a0a34003a1f024ca7c50694e28b03d4cd4faa4b2026ff44979579/pydantic_ai_slim-2.22.0.tar.gz", hash = "sha256:00a09316951ba4587348a5233a5d0b395ed7cf97ee3991666841bd55e41ddc85", size = 939122, upload-time = "2026-08-01T02:38:22.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/9f/53b19efefa041c1080f7c4ad41679a9293cce64f1265168a98cbe06a0ab7/pydantic_ai_slim-2.23.0.tar.gz", hash = "sha256:d16dcbfb2bfea0ee162bf0f499442fab5a4d69b41e4c54f3b694c2e90b983768", size = 965485, upload-time = "2026-08-04T01:58:20.668Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/0f/a9e71b83a74bb5773e31c5ccd1ad8d753fb99554be7842c9f1da18168772/pydantic_ai_slim-2.22.0-py3-none-any.whl", hash = "sha256:156e772b1a4a568c65d779ae1e1012dd58bc255e5355067981a490e9acd7cb45", size = 1130161, upload-time = "2026-08-01T02:38:15.741Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6f/539a255524178a8421d582271a8d7f8667b036f02b4ddc4f20abcc63888b/pydantic_ai_slim-2.23.0-py3-none-any.whl", hash = "sha256:a2fa3e56408bbf1b83900e3dd4ad9b137297742f450863c2f0f9a03a547d0e33", size = 1157486, upload-time = "2026-08-04T01:58:12.356Z" }, ] [package.optional-dependencies] @@ -2475,7 +2475,7 @@ wheels = [ [[package]] name = "pydantic-evals" -version = "2.22.0" +version = "2.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2485,14 +2485,14 @@ dependencies = [ { name = "pyyaml" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/40/96f0c745b1a42188b440ae4ca618bb17ea3f0b3a5556d015bb016d03d283/pydantic_evals-2.22.0.tar.gz", hash = "sha256:19d51db9b419b14303ae92c7e416452eb3aed358000ea8365a011bde6d5f67bf", size = 85388, upload-time = "2026-08-01T02:38:23.72Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/4e/ac3bcbbefe683991e8cbd3f69c624c2d550002e9f33fe03ba9e69309ef94/pydantic_evals-2.23.0.tar.gz", hash = "sha256:3f5e16708976c165ae23109f55143fa3d68a3f569b35bc70ca7c54cf737df63e", size = 85391, upload-time = "2026-08-04T01:58:21.933Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/24/812502d1ee4802a8d32195fefd8754389fc87766e36b787edfef7054dff3/pydantic_evals-2.22.0-py3-none-any.whl", hash = "sha256:ff59d93cf794ed5e3fd84ad7e76c9ca03362e1169834d77bb84da110e65f9278", size = 100539, upload-time = "2026-08-01T02:38:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/72/569511b3de588615a9151b727dedc181d2d54d5434d55b768dc26fa20ab0/pydantic_evals-2.23.0-py3-none-any.whl", hash = "sha256:8cde69fc2e126b20372488187b016f329fab710bd325d10dc4083a9e19ff04b2", size = 100540, upload-time = "2026-08-04T01:58:14.431Z" }, ] [[package]] name = "pydantic-graph" -version = "2.22.0" +version = "2.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2501,9 +2501,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/a8/6c0a831e111f017339f2463b03eba6ab3cdf86c789f9d5ff279031df8204/pydantic_graph-2.22.0.tar.gz", hash = "sha256:1350e63b1af5cea421aba7ed996af5d853b5b1da3c92b7cf7439d558bda4c8dd", size = 45180, upload-time = "2026-08-01T02:38:24.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/fc/273bac7d14fb62c060e0c20c51a9cc60e9e90a96d992fd20e77abf4b6ac1/pydantic_graph-2.23.0.tar.gz", hash = "sha256:54c9939f47fd8a268c96320d7d90e7cef037cbfd2625a675dc4028c1377f70ab", size = 45179, upload-time = "2026-08-04T01:58:23.085Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/38/3e51ef169df4d7c3fb6196b51064f3f39b2a9ee9b99edeb412333273825a/pydantic_graph-2.22.0-py3-none-any.whl", hash = "sha256:26663839b426114834e3b9047ae6620c2a0a88cdefe67e63a3074d378ff9ed8a", size = 52661, upload-time = "2026-08-01T02:38:18.621Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c4/875cf853d205dc55422bd44ff0fbfac82e6e34ff7693df016fc3a4088d32/pydantic_graph-2.23.0-py3-none-any.whl", hash = "sha256:b0f12b4f72adb2a5522b5962c95e1a7b140cb3f631a628036f935e291a9e50ba", size = 52662, upload-time = "2026-08-04T01:58:15.858Z" }, ] [[package]]