From 16fed97cc47fdc7442ee572e5d5a15acb69251db Mon Sep 17 00:00:00 2001 From: James Weaver Date: Wed, 15 Apr 2026 09:47:31 +0100 Subject: [PATCH 01/14] merchant requires age verification for restricted products - paymentmandate co-signing --- .../scenarios/a2a/human-present/cards/run.sh | 4 + .../sub_agents/catalog_agent.py | 21 +++ .../roles/oneid_identity_provider/__main__.py | 37 +++++ .../roles/oneid_identity_provider/agent.json | 25 ++++ .../oneid_identity_provider/agent_executor.py | 55 +++++++ .../roles/oneid_identity_provider/tools.py | 139 ++++++++++++++++++ .../python/src/roles/shopping_agent/agent.py | 15 +- .../src/roles/shopping_agent/remote_agents.py | 9 ++ .../shopping_agent/subagents/shopper/tools.py | 17 ++- .../python/src/roles/shopping_agent/tools.py | 113 +++++++++++++- 10 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 samples/python/src/roles/oneid_identity_provider/__main__.py create mode 100644 samples/python/src/roles/oneid_identity_provider/agent.json create mode 100644 samples/python/src/roles/oneid_identity_provider/agent_executor.py create mode 100644 samples/python/src/roles/oneid_identity_provider/tools.py diff --git a/samples/python/scenarios/a2a/human-present/cards/run.sh b/samples/python/scenarios/a2a/human-present/cards/run.sh index 2745b78b..aabc983c 100755 --- a/samples/python/scenarios/a2a/human-present/cards/run.sh +++ b/samples/python/scenarios/a2a/human-present/cards/run.sh @@ -130,6 +130,10 @@ echo "-> Starting the Card Processor Agent (port:8003 log:$LOG_DIR/mpp_agent.log $UV_RUN_CMD --package ap2-samples python -m roles.merchant_payment_processor_agent >"$LOG_DIR/mpp_agent.log" 2>&1 & pids+=($!) +echo "-> Starting the OneID Identity Provider (port:8004 log:$LOG_DIR/oneid_identity_provider.log)..." +$UV_RUN_CMD --package ap2-samples python -m roles.oneid_identity_provider >"$LOG_DIR/oneid_identity_provider.log" 2>&1 & +pids+=($!) + echo "" echo "All remote servers are starting." diff --git a/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py b/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py index b40f72e0..3d9f8335 100644 --- a/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py +++ b/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py @@ -46,6 +46,15 @@ from common.system_utils import DEBUG_MODE_INSTRUCTIONS +_AGE_RESTRICTED_KEYWORDS = [ + "wine", "beer", "ale", "lager", "stout", "cider", + "whisky", "whiskey", "vodka", "gin", "rum", "brandy", + "champagne", "prosecco", "spirit", "liqueur", "bourbon", + "alcohol", "tobacco", "cigarette", "cigar", "vape", + "age 18+", "[age 18+ required]", +] + + async def find_items_workflow( data_parts: list[dict[str, Any]], updater: TaskUpdater, @@ -64,6 +73,10 @@ async def find_items_workflow( You MUST exclude all branding from the PaymentItem `label` field. + If any item is typically age-restricted (alcohol, wine, beer, spirits, + tobacco, or cigarettes), you MUST append ' [Age 18+ required]' to that + item's label field. + %s """ % DEBUG_MODE_INSTRUCTIONS @@ -78,6 +91,11 @@ async def find_items_workflow( try: items: list[PaymentItem] = llm_response.parsed + age_verification_required = any( + any(kw in item.label.lower() for kw in _AGE_RESTRICTED_KEYWORDS) + for item in items + ) + current_time = datetime.now(timezone.utc) item_count = 0 for item in items: @@ -93,6 +111,9 @@ async def find_items_workflow( updater.add_artifact([ Part(root=DataPart(data={"risk_data": risk_data})), ]) + updater.add_artifact([ + Part(root=DataPart(data={"age_verification_required": age_verification_required})), + ]) await updater.complete() except ValidationError as e: error_message = updater.new_agent_message( diff --git a/samples/python/src/roles/oneid_identity_provider/__main__.py b/samples/python/src/roles/oneid_identity_provider/__main__.py new file mode 100644 index 00000000..7c76be06 --- /dev/null +++ b/samples/python/src/roles/oneid_identity_provider/__main__.py @@ -0,0 +1,37 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OneID Identity Provider Agent — port 8004.""" + +from collections.abc import Sequence + +from absl import app +from roles.oneid_identity_provider.agent_executor import OneIDIdentityProviderExecutor +from common import server + +AGENT_PORT = 8004 + + +def main(argv: Sequence[str]) -> None: + agent_card = server.load_local_agent_card(__file__) + server.run_agent_blocking( + port=AGENT_PORT, + agent_card=agent_card, + executor=OneIDIdentityProviderExecutor(agent_card.capabilities.extensions), + rpc_url="/a2a/oneid_identity_provider", + ) + + +if __name__ == "__main__": + app.run(main) diff --git a/samples/python/src/roles/oneid_identity_provider/agent.json b/samples/python/src/roles/oneid_identity_provider/agent.json new file mode 100644 index 00000000..a79e0402 --- /dev/null +++ b/samples/python/src/roles/oneid_identity_provider/agent.json @@ -0,0 +1,25 @@ +{ + "name": "OneIDIdentityProvider", + "description": "An agent that verifies user identity using bank-verified data via OneID.", + "capabilities": { + "extensions": [ + { + "uri": "https://github.com/google-agentic-commerce/ap2/v1", + "description": "Supports the Agent Payments Protocol.", + "required": true + } + ] + }, + "skills": [ + { + "id": "identity-verification", + "name": "Identity Verification", + "description": "Verifies user identity and age using UK bank authentication. Returns verified claims as a JWT Verifiable Credential.", + "tags": ["identity", "age-verification", "kyc"] + } + ], + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["application/json"], + "url": "http://localhost:8004/a2a/oneid_identity_provider", + "version": "1.0.0" +} diff --git a/samples/python/src/roles/oneid_identity_provider/agent_executor.py b/samples/python/src/roles/oneid_identity_provider/agent_executor.py new file mode 100644 index 00000000..56abad72 --- /dev/null +++ b/samples/python/src/roles/oneid_identity_provider/agent_executor.py @@ -0,0 +1,55 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""An A2A Agent Executor for the OneID Identity Provider agent. + +OneID verifies user identity via UK Open Banking: the user authenticates with +their bank (biometrics/PIN) and OneID returns verified claims including age +thresholds, as a JWT Verifiable Credential. + +In order to clearly demonstrate the use of the Agent Payments Protocol A2A +extension, this agent was built directly using the A2A framework. +""" + +from typing import Any + +from . import tools +from common.base_server_executor import BaseServerExecutor +from common.system_utils import DEBUG_MODE_INSTRUCTIONS + + +class OneIDIdentityProviderExecutor(BaseServerExecutor): + """AgentExecutor for the OneID identity provider agent.""" + + _system_prompt = """ + You are OneID, a UK identity verification provider. Your job is to verify + user identity using UK Open Banking authentication and return verified + claims as a Verifiable Credential. + + Based on the user's request, identify their intent and select the single + correct tool to use. Your only output should be a tool call. + Do not engage in conversation. + + %s + """ % DEBUG_MODE_INSTRUCTIONS + + def __init__(self, supported_extensions: list[dict[str, Any]] = None): + """Initializes the OneIDIdentityProviderExecutor. + + Args: + supported_extensions: A list of extension objects supported by the + agent. + """ + agent_tools = [tools.handle_verify_identity] + super().__init__(supported_extensions, agent_tools, self._system_prompt) diff --git a/samples/python/src/roles/oneid_identity_provider/tools.py b/samples/python/src/roles/oneid_identity_provider/tools.py new file mode 100644 index 00000000..ec8e3714 --- /dev/null +++ b/samples/python/src/roles/oneid_identity_provider/tools.py @@ -0,0 +1,139 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tools for the OneID Identity Provider Agent. + +OneID verifies user identity via UK Open Banking. The verification flow +mirrors the OTP challenge pattern used by the merchant payment processor: + + 1. First call (current_task is None): issue an identity challenge. + 2. Second call (same task_id, challenge_response present): validate the + response and return a mock JWT Verifiable Credential containing verified + claims (age_over_18, bank, framework, assurance_level). +""" + +from datetime import datetime +from datetime import timezone +from typing import Any + +from a2a.server.tasks.task_updater import TaskUpdater +from a2a.types import DataPart +from a2a.types import Part +from a2a.types import Task +from a2a.types import TaskState +from a2a.types import TextPart +from common import message_utils + + +IDENTITY_CREDENTIAL_DATA_KEY = "oneid.IdentityCredential" + +# A structurally realistic but cryptographically invalid mock JWT for demo use. +_MOCK_JWT = ( + "eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK2p3dCJ9" + ".eyJzdWIiOiJ1c2VyIiwiaXNzIjoiaHR0cHM6Ly9vbmVpZC51ayIsImlhdCI6MTc0NTA3" + "MDAwMCwidmMiOnsiY3JlZGVudGlhbFN1YmplY3QiOnsiYWdlX292ZXJfMTgiOnRydWV9fX0" + ".DEMO_SIGNATURE_NOT_CRYPTOGRAPHICALLY_VALID" +) + + +async def handle_verify_identity( + data_parts: list[dict[str, Any]], + updater: TaskUpdater, + current_task: Task | None, +) -> None: + """Handles an identity verification request. + + First call: issues a bank-authentication challenge (requires_input). + Second call (same task_id): validates the response and returns a JWT VC. + + Args: + data_parts: The incoming data parts from the A2A message. + updater: The task updater for managing task state. + current_task: The current task, or None if this is a new request. + """ + if current_task is None: + await _raise_identity_challenge(updater) + return + + if current_task.status.state == TaskState.input_required: + challenge_response = ( + message_utils.find_data_part("challenge_response", data_parts) or "" + ) + if challenge_response: + await _complete_identity_verification(updater) + else: + # Re-issue the challenge if no response was provided. + await _raise_identity_challenge(updater) + + +async def _raise_identity_challenge(updater: TaskUpdater) -> None: + """Issues the OneID bank-authentication challenge. + + In a real OneID flow the user would be redirected to their banking app to + authenticate. Here we mock this as a chat-based confirmation, matching the + same pattern the demo uses for the OTP challenge. + + Args: + updater: The task updater. + """ + challenge_data = { + "type": "identity_verification", + "display_text": ( + "OneID needs to verify your age for this purchase. Your bank " + "(Lloyds Bank) will confirm you are 18+ via UK Open Banking. " + "In a real flow you would be redirected to your banking app to " + "authenticate with biometrics or PIN. " + "(Demo: type 'confirm' to simulate completing bank authentication)" + ), + } + text_part = TextPart(text="Identity verification required.") + data_part = DataPart(data={"challenge": challenge_data}) + message = updater.new_agent_message( + parts=[Part(root=text_part), Part(root=data_part)] + ) + await updater.requires_input(message=message) + + +async def _complete_identity_verification(updater: TaskUpdater) -> None: + """Returns the mock JWT Verifiable Credential after successful authentication. + + In production OneID would return a signed JWT VC from https://oneid.uk. + For this demo the JWT is structurally realistic but not cryptographically + valid — the proof.jwt is a placeholder. + + Args: + updater: The task updater. + """ + credential = { + "type": "VerifiableCredential", + "issuer": "https://oneid.uk", + "issuance_date": datetime.now(timezone.utc).isoformat(), + "credential_subject": { + "age_over_18": True, + "verification_method": "uk_open_banking", + "bank": "Lloyds Bank", + "assurance_level": "high", + "framework": "UK_DIATF", + }, + "proof": { + "type": "jwt", + "jwt": _MOCK_JWT, + }, + } + data_part = DataPart(data={IDENTITY_CREDENTIAL_DATA_KEY: credential}) + await updater.add_artifact([Part(root=data_part)]) + success_message = updater.new_agent_message( + parts=[Part(root=TextPart(text="Identity verification successful."))] + ) + await updater.complete(message=success_message) diff --git a/samples/python/src/roles/shopping_agent/agent.py b/samples/python/src/roles/shopping_agent/agent.py index 37c91a31..20601772 100644 --- a/samples/python/src/roles/shopping_agent/agent.py +++ b/samples/python/src/roles/shopping_agent/agent.py @@ -49,6 +49,14 @@ 1. Delegate to the `shopper` agent to collect the products the user is interested in purchasing. The `shopper` agent will return a message indicating if the chosen cart mandate is ready or not. + 1b. After the shopper agent returns: if the shopper's response + mentions that age verification is required (e.g. for alcohol or + other age-restricted items), call `request_identity_verification`. + Present the challenge text returned to the user exactly as given. + 1c. Once the user confirms (any non-empty response is accepted for + this demo), call `complete_identity_verification` with their + response. Show the verification result to the user before + continuing to step 2. 2. Once a success message is received, delegate to the `shipping_address_collector` agent to collect the user's shipping address. @@ -88,7 +96,10 @@ price, shipping, tax and total price. In a second block, show the shipping address. Format it all nicely. In a third block, show the user's payment method alias. Format it nicely and give it to the - user. + user. If identity verification was performed, include a fourth + block titled 'Identity Verification' showing: bank used, + framework (UK_DIATF), assurance level, and age_over_18 + confirmation. Scenario 2: The user first wants you to describe all the data passed between you, @@ -112,6 +123,8 @@ tools.create_payment_mandate, tools.initiate_payment, tools.initiate_payment_with_otp, + tools.request_identity_verification, + tools.complete_identity_verification, tools.send_signed_payment_mandate_to_credentials_provider, tools.sign_mandates_on_user_device, tools.update_cart, diff --git a/samples/python/src/roles/shopping_agent/remote_agents.py b/samples/python/src/roles/shopping_agent/remote_agents.py index 9c2a492f..d5b8a4bd 100644 --- a/samples/python/src/roles/shopping_agent/remote_agents.py +++ b/samples/python/src/roles/shopping_agent/remote_agents.py @@ -41,3 +41,12 @@ EXTENSION_URI, }, ) + + +oneid_identity_provider_client = PaymentRemoteA2aClient( + name="oneid_identity_provider", + base_url="http://localhost:8004/a2a/oneid_identity_provider", + required_extensions={ + EXTENSION_URI, + }, +) diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py index cee16eff..c76f2564 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py @@ -107,6 +107,8 @@ async def find_products( tool_context.state["shopping_context_id"] = task.context_id cart_mandates = _parse_cart_mandates(task.artifacts) tool_context.state["cart_mandates"] = cart_mandates + age_verification_required = _parse_age_verification_required(task.artifacts) + tool_context.state["age_verification_required"] = age_verification_required return cart_mandates @@ -124,7 +126,10 @@ def update_chosen_cart_mandate(cart_id: str, tool_context: ToolContext) -> str: ) if cart.contents.id == cart_id: tool_context.state["chosen_cart_id"] = cart_id - return f"CartMandate with ID {cart_id} selected." + age_note = "" + if tool_context.state.get("age_verification_required"): + age_note = " Age verification (18+) is required for items in this cart." + return f"CartMandate with ID {cart_id} selected.{age_note}" return f"CartMandate with ID {cart_id} not found." @@ -133,6 +138,16 @@ def _parse_cart_mandates(artifacts: list[Artifact]) -> list[CartMandate]: return find_canonical_objects(artifacts, CART_MANDATE_DATA_KEY, CartMandate) +def _parse_age_verification_required(artifacts: list) -> bool: + """Returns True if the merchant indicated age verification is required.""" + for artifact in (artifacts or []): + for part in (artifact.parts or []): + data = getattr(part.root, "data", None) + if data and "age_verification_required" in data: + return bool(data["age_verification_required"]) + return False + + def _collect_risk_data(tool_context: ToolContext) -> dict: """Creates a risk_data in the tool_context.""" # This is a fake risk data for demonstration purposes. diff --git a/samples/python/src/roles/shopping_agent/tools.py b/samples/python/src/roles/shopping_agent/tools.py index 1d8b7bf1..01c1f6ba 100644 --- a/samples/python/src/roles/shopping_agent/tools.py +++ b/samples/python/src/roles/shopping_agent/tools.py @@ -28,6 +28,7 @@ from .remote_agents import credentials_provider_client from .remote_agents import merchant_agent_client +from .remote_agents import oneid_identity_provider_client from ap2.types.contact_picker import ContactAddress from ap2.types.mandate import CART_MANDATE_DATA_KEY from ap2.types.mandate import CartMandate @@ -82,6 +83,107 @@ async def update_cart( return updated_cart_mandate +async def request_identity_verification( + tool_context: ToolContext, + debug_mode: bool = False, +) -> str: + """Requests age/identity verification from the OneID Identity Provider. + + Sends an A2A request to OneID and returns the challenge text to present + to the user. Stores the task ID for use in complete_identity_verification. + + Args: + tool_context: The ADK supplied tool context. + debug_mode: Whether the agent is in debug mode. + + Returns: + The challenge display text to show to the user. + """ + message = ( + A2aMessageBuilder() + .set_context_id(tool_context.state["shopping_context_id"]) + .add_text("verify_identity") + .add_data("verification_requirements", {"age_over_18": True}) + .add_data("debug_mode", debug_mode) + .build() + ) + task = await oneid_identity_provider_client.send_a2a_message(message) + tool_context.state["oneid_task_id"] = task.id + + # Extract challenge text from the task status message. + if task.status and task.status.message: + for part in (task.status.message.parts or []): + data = getattr(part.root, "data", None) + if data and "challenge" in data: + return data["challenge"].get("display_text", "") + + return ( + "OneID requires you to verify your age. " + "Type 'confirm' to simulate bank authentication." + ) + + +async def complete_identity_verification( + confirmation: str, + tool_context: ToolContext, + debug_mode: bool = False, +) -> str: + """Completes identity verification with the user's confirmation. + + Re-sends the identity verification request to OneID with the user's + confirmation (challenge response) using the existing task ID, mirroring + the OTP challenge-response pattern. + + Args: + confirmation: The user's confirmation response. + tool_context: The ADK supplied tool context. + debug_mode: Whether the agent is in debug mode. + + Returns: + Human-readable verification result including the verified claims. + """ + oneid_task_id = tool_context.state.get("oneid_task_id") + if not oneid_task_id: + raise RuntimeError( + "No OneID task ID found. Call request_identity_verification first." + ) + + message = ( + A2aMessageBuilder() + .set_context_id(tool_context.state["shopping_context_id"]) + .set_task_id(oneid_task_id) + .add_text("verify_identity") + .add_data("verification_requirements", {"age_over_18": True}) + .add_data("challenge_response", confirmation) + .add_data("debug_mode", debug_mode) + .build() + ) + task = await oneid_identity_provider_client.send_a2a_message(message) + + # Extract identity credential from artifacts. + identity_credential = None + for artifact in (task.artifacts or []): + for part in (artifact.parts or []): + data = getattr(part.root, "data", None) + if data and "oneid.IdentityCredential" in data: + identity_credential = data["oneid.IdentityCredential"] + break + + if identity_credential: + tool_context.state["identity_credential"] = identity_credential + subject = identity_credential.get("credential_subject", {}) + return ( + f"Identity verification successful. " + f"Verified: age_over_18={subject.get('age_over_18')}, " + f"bank={subject.get('bank')}, " + f"framework={subject.get('framework')}, " + f"assurance_level={subject.get('assurance_level')}. " + f"Issuer: {identity_credential.get('issuer')}." + ) + + return "Identity verification failed or returned no credential." + + async def initiate_payment(tool_context: ToolContext, debug_mode: bool = False): """Initiates a payment using the payment mandate from state. @@ -107,9 +209,16 @@ async def initiate_payment(tool_context: ToolContext, debug_mode: bool = False): .add_data("risk_data", risk_data) .add_data("shopping_agent_id", "trusted_shopping_agent") .add_data("debug_mode", debug_mode) - .build() ) - task = await merchant_agent_client.send_a2a_message(outgoing_message_builder) + # Include identity credential as evidence if age verification was performed. + identity_credential = tool_context.state.get("identity_credential") + if identity_credential: + outgoing_message_builder.add_data( + "oneid.IdentityCredential", identity_credential + ) + task = await merchant_agent_client.send_a2a_message( + outgoing_message_builder.build() + ) store_receipt_if_present(task, tool_context) tool_context.state["initiate_payment_task_id"] = task.id return task.status From af0a59280a1607a07e33fdbff5775b8eaa7aec20 Mon Sep 17 00:00:00 2001 From: James Weaver Date: Wed, 15 Apr 2026 13:18:22 +0100 Subject: [PATCH 02/14] move from age verification to identity verification - co-signing on intent and cart mandates --- .../sub_agents/catalog_agent.py | 22 --- .../roles/oneid_identity_provider/__main__.py | 14 -- .../oneid_identity_provider/agent_executor.py | 14 -- .../roles/oneid_identity_provider/tools.py | 73 ++++---- .../python/src/roles/shopping_agent/agent.py | 39 ++-- .../shopping_agent/subagents/shopper/agent.py | 17 +- .../shopping_agent/subagents/shopper/tools.py | 154 ++++++++++++++-- .../python/src/roles/shopping_agent/tools.py | 174 +++++++----------- src/ap2/types/mandate.py | 42 +++++ 9 files changed, 333 insertions(+), 216 deletions(-) diff --git a/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py b/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py index 3d9f8335..a6bd4823 100644 --- a/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py +++ b/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py @@ -46,15 +46,6 @@ from common.system_utils import DEBUG_MODE_INSTRUCTIONS -_AGE_RESTRICTED_KEYWORDS = [ - "wine", "beer", "ale", "lager", "stout", "cider", - "whisky", "whiskey", "vodka", "gin", "rum", "brandy", - "champagne", "prosecco", "spirit", "liqueur", "bourbon", - "alcohol", "tobacco", "cigarette", "cigar", "vape", - "age 18+", "[age 18+ required]", -] - - async def find_items_workflow( data_parts: list[dict[str, Any]], updater: TaskUpdater, @@ -73,10 +64,6 @@ async def find_items_workflow( You MUST exclude all branding from the PaymentItem `label` field. - If any item is typically age-restricted (alcohol, wine, beer, spirits, - tobacco, or cigarettes), you MUST append ' [Age 18+ required]' to that - item's label field. - %s """ % DEBUG_MODE_INSTRUCTIONS @@ -90,12 +77,6 @@ async def find_items_workflow( ) try: items: list[PaymentItem] = llm_response.parsed - - age_verification_required = any( - any(kw in item.label.lower() for kw in _AGE_RESTRICTED_KEYWORDS) - for item in items - ) - current_time = datetime.now(timezone.utc) item_count = 0 for item in items: @@ -111,9 +92,6 @@ async def find_items_workflow( updater.add_artifact([ Part(root=DataPart(data={"risk_data": risk_data})), ]) - updater.add_artifact([ - Part(root=DataPart(data={"age_verification_required": age_verification_required})), - ]) await updater.complete() except ValidationError as e: error_message = updater.new_agent_message( diff --git a/samples/python/src/roles/oneid_identity_provider/__main__.py b/samples/python/src/roles/oneid_identity_provider/__main__.py index 7c76be06..17543a09 100644 --- a/samples/python/src/roles/oneid_identity_provider/__main__.py +++ b/samples/python/src/roles/oneid_identity_provider/__main__.py @@ -1,17 +1,3 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - """OneID Identity Provider Agent — port 8004.""" from collections.abc import Sequence diff --git a/samples/python/src/roles/oneid_identity_provider/agent_executor.py b/samples/python/src/roles/oneid_identity_provider/agent_executor.py index 56abad72..f8923984 100644 --- a/samples/python/src/roles/oneid_identity_provider/agent_executor.py +++ b/samples/python/src/roles/oneid_identity_provider/agent_executor.py @@ -1,17 +1,3 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - """An A2A Agent Executor for the OneID Identity Provider agent. OneID verifies user identity via UK Open Banking: the user authenticates with diff --git a/samples/python/src/roles/oneid_identity_provider/tools.py b/samples/python/src/roles/oneid_identity_provider/tools.py index ec8e3714..aaf9187c 100644 --- a/samples/python/src/roles/oneid_identity_provider/tools.py +++ b/samples/python/src/roles/oneid_identity_provider/tools.py @@ -1,26 +1,16 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - """Tools for the OneID Identity Provider Agent. -OneID verifies user identity via UK Open Banking. The verification flow -mirrors the OTP challenge pattern used by the merchant payment processor: +OneID verifies user identity and returns a JWT Verifiable Credential +containing the verified claims (scopes) requested by the Relying Party. +The verification method (e.g. UK Open Banking, document scan) is an +implementation detail not exposed to the RP — only the verified scopes +and assurance level are returned. +The verification flow mirrors the OTP challenge pattern: 1. First call (current_task is None): issue an identity challenge. 2. Second call (same task_id, challenge_response present): validate the - response and return a mock JWT Verifiable Credential containing verified - claims (age_over_18, bank, framework, assurance_level). + response and return a mock JWT VC containing verified_scopes, + assurance_level, and framework. """ from datetime import datetime @@ -62,6 +52,13 @@ async def handle_verify_identity( updater: The task updater for managing task state. current_task: The current task, or None if this is a new request. """ + verification_requirements = ( + message_utils.find_data_part("verification_requirements", data_parts) or {} + ) + # Scope the credential to the IntentMandate's expiry so it cannot be reused + # beyond the transaction session it was issued for. + valid_until = verification_requirements.get("valid_until") + if current_task is None: await _raise_identity_challenge(updater) return @@ -71,18 +68,20 @@ async def handle_verify_identity( message_utils.find_data_part("challenge_response", data_parts) or "" ) if challenge_response: - await _complete_identity_verification(updater) + await _complete_identity_verification(updater, valid_until=valid_until) else: # Re-issue the challenge if no response was provided. await _raise_identity_challenge(updater) async def _raise_identity_challenge(updater: TaskUpdater) -> None: - """Issues the OneID bank-authentication challenge. + """Issues the OneID identity verification challenge. - In a real OneID flow the user would be redirected to their banking app to - authenticate. Here we mock this as a chat-based confirmation, matching the - same pattern the demo uses for the OTP challenge. + In a real OneID flow the user would be redirected to their chosen + identity provider (e.g. their bank via UK Open Banking, or a document + scan service) to authenticate. Here we mock this as a chat-based + confirmation, matching the same pattern the demo uses for the OTP + challenge. Args: updater: The task updater. @@ -90,11 +89,11 @@ async def _raise_identity_challenge(updater: TaskUpdater) -> None: challenge_data = { "type": "identity_verification", "display_text": ( - "OneID needs to verify your age for this purchase. Your bank " - "(Lloyds Bank) will confirm you are 18+ via UK Open Banking. " - "In a real flow you would be redirected to your banking app to " - "authenticate with biometrics or PIN. " - "(Demo: type 'confirm' to simulate completing bank authentication)" + "OneID needs to verify your identity for this purchase. " + "In a real flow you would be redirected to your chosen identity " + "provider to authenticate (e.g. via UK Open Banking or document " + "scan). OneID will confirm the requested claims on your behalf. " + "(Demo: type 'confirm' to simulate completing identity verification)" ), } text_part = TextPart(text="Identity verification required.") @@ -105,7 +104,10 @@ async def _raise_identity_challenge(updater: TaskUpdater) -> None: await updater.requires_input(message=message) -async def _complete_identity_verification(updater: TaskUpdater) -> None: +async def _complete_identity_verification( + updater: TaskUpdater, + valid_until: str | None = None, +) -> None: """Returns the mock JWT Verifiable Credential after successful authentication. In production OneID would return a signed JWT VC from https://oneid.uk. @@ -114,15 +116,21 @@ async def _complete_identity_verification(updater: TaskUpdater) -> None: Args: updater: The task updater. + valid_until: ISO 8601 expiry copied from the IntentMandate's intent_expiry. + When present, the credential is scoped to this time so it cannot be + reused beyond the current transaction session. """ + # The credential_subject contains only the verified claims (scopes) that + # were requested and confirmed. The verification method (e.g. which bank + # or document provider was used) is an internal OneID detail and is NOT + # returned to the Relying Party. credential = { "type": "VerifiableCredential", "issuer": "https://oneid.uk", "issuance_date": datetime.now(timezone.utc).isoformat(), "credential_subject": { + "verified_scopes": ["age_over_18", "openid"], "age_over_18": True, - "verification_method": "uk_open_banking", - "bank": "Lloyds Bank", "assurance_level": "high", "framework": "UK_DIATF", }, @@ -131,6 +139,9 @@ async def _complete_identity_verification(updater: TaskUpdater) -> None: "jwt": _MOCK_JWT, }, } + if valid_until: + # Bind the credential lifetime to the IntentMandate's expiry. + credential["expiration_date"] = valid_until data_part = DataPart(data={IDENTITY_CREDENTIAL_DATA_KEY: credential}) await updater.add_artifact([Part(root=data_part)]) success_message = updater.new_agent_message( diff --git a/samples/python/src/roles/shopping_agent/agent.py b/samples/python/src/roles/shopping_agent/agent.py index 20601772..6752b0f0 100644 --- a/samples/python/src/roles/shopping_agent/agent.py +++ b/samples/python/src/roles/shopping_agent/agent.py @@ -49,22 +49,21 @@ 1. Delegate to the `shopper` agent to collect the products the user is interested in purchasing. The `shopper` agent will return a message indicating if the chosen cart mandate is ready or not. - 1b. After the shopper agent returns: if the shopper's response - mentions that age verification is required (e.g. for alcohol or - other age-restricted items), call `request_identity_verification`. - Present the challenge text returned to the user exactly as given. - 1c. Once the user confirms (any non-empty response is accepted for - this demo), call `complete_identity_verification` with their - response. Show the verification result to the user before - continuing to step 2. 2. Once a success message is received, delegate to the `shipping_address_collector` agent to collect the user's shipping address. 3. The shipping_address_collector agent will return the user's shipping address. Display the shipping address to the user. 4. Once you have the shipping address, call the `update_cart` tool to - update the cart. You will receive a new, signed `CartMandate` - object. + update the cart with the merchant. + 4b. Display the updated cart to the user — show the item, shipping + address, and final total including shipping. Then ask: + "Your cart is ready. To complete this purchase as a verified + user, your confirmed identity from OneID will be bound to this + cart. Shall I co-sign it on your behalf?" + 4c. Once the user confirms, call the `cosign_cart_mandate` tool and + show the result to the user (e.g. "Done — your cart has been + co-signed with your verified OneID identity."). 5. Delegate to the `payment_method_collector` agent to collect the user's payment method. 6. The `payment_method_collector` agent will return the user's @@ -79,7 +78,11 @@ human-readable format) and how long it can be refunded (in a human-readable format). In a second block, show the shipping address. Format it all nicely. In a third block, show the user's - payment method alias. Format it nicely. + payment method alias. Format it nicely. In a fourth block titled + 'Identity Verification', show the verified scopes (e.g. + age_over_18, openid), the framework (UK_DIATF), and assurance + level from the OneID credential, and note that both the + IntentMandate and CartMandate have been co-signed. 10. Confirm with the user they want to purchase the selected item using the selected form of payment. 11. When the user confirms purchase call the following tools in order: @@ -95,11 +98,12 @@ text titled 'Payment Receipt'. Ensure its contents includes price, shipping, tax and total price. In a second block, show the shipping address. Format it all nicely. In a third block, show the - user's payment method alias. Format it nicely and give it to the - user. If identity verification was performed, include a fourth - block titled 'Identity Verification' showing: bank used, - framework (UK_DIATF), assurance level, and age_over_18 - confirmation. + user's payment method alias. Format it nicely. If identity + verification was performed, include a fourth block titled + 'Identity Verification' showing: the verified scopes (e.g. + age_over_18, openid), framework (UK_DIATF), assurance level, + and a note that the IntentMandate and CartMandate were both + co-signed with the OneID credential. Scenario 2: The user first wants you to describe all the data passed between you, @@ -120,11 +124,10 @@ you can say 'I want to buy a pair of shoes'" """ % DEBUG_MODE_INSTRUCTIONS, tools=[ + tools.cosign_cart_mandate, tools.create_payment_mandate, tools.initiate_payment, tools.initiate_payment_with_otp, - tools.request_identity_verification, - tools.complete_identity_verification, tools.send_signed_payment_mandate_to_credentials_provider, tools.sign_mandates_on_user_device, tools.update_cart, diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py index d380fac7..ff386eed 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py @@ -74,8 +74,19 @@ human-readable relative time (e.g., "in 1 hour", "in 2 days"). After the breakdown, leave a blank line and end with: "Shall I proceed?" - 5. Once the user confirms, use the 'find_products' tool. It will - return a list of `CartMandate` objects. + 4b. Once the user confirms the IntentMandate, call the + `request_identity_verification` tool. Present the challenge text it + returns to the user verbatim. This establishes who the real person + behind the transaction is, binding their verified identity to the + Cart Mandate that will be co-signed later in the flow. + 4c. Once the user responds (any non-empty reply is accepted in this demo), + call `complete_identity_verification` with their response. Show the + full verification result to the user, making clear that the + IntentMandate has been co-signed with their verified OneID identity + — include the verified scopes, framework, and assurance level. + Then proceed to step 5. + 5. Once identity verification is complete, use the 'find_products' tool. It + will return a list of `CartMandate` objects. 6. For each CartMandate object in the list, create a visually distinct entry that includes the following details from the object: Item: Display the item_name clearly and in bold. @@ -101,6 +112,8 @@ """ % DEBUG_MODE_INSTRUCTIONS, tools=[ tools.create_intent_mandate, + tools.request_identity_verification, + tools.complete_identity_verification, tools.find_products, tools.update_chosen_cart_mandate, ], diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py index c76f2564..c4c17741 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py @@ -18,9 +18,11 @@ shopping and purchasing process. """ +import base64 from datetime import datetime from datetime import timedelta from datetime import timezone +import json from a2a.types import Artifact from google.adk.tools.tool_context import ToolContext @@ -32,6 +34,7 @@ from common.a2a_message_builder import A2aMessageBuilder from common.artifact_utils import find_canonical_objects from roles.shopping_agent.remote_agents import merchant_agent_client +from roles.shopping_agent.remote_agents import oneid_identity_provider_client def create_intent_mandate( @@ -107,8 +110,6 @@ async def find_products( tool_context.state["shopping_context_id"] = task.context_id cart_mandates = _parse_cart_mandates(task.artifacts) tool_context.state["cart_mandates"] = cart_mandates - age_verification_required = _parse_age_verification_required(task.artifacts) - tool_context.state["age_verification_required"] = age_verification_required return cart_mandates @@ -126,10 +127,7 @@ def update_chosen_cart_mandate(cart_id: str, tool_context: ToolContext) -> str: ) if cart.contents.id == cart_id: tool_context.state["chosen_cart_id"] = cart_id - age_note = "" - if tool_context.state.get("age_verification_required"): - age_note = " Age verification (18+) is required for items in this cart." - return f"CartMandate with ID {cart_id} selected.{age_note}" + return f"CartMandate with ID {cart_id} selected." return f"CartMandate with ID {cart_id} not found." @@ -138,14 +136,146 @@ def _parse_cart_mandates(artifacts: list[Artifact]) -> list[CartMandate]: return find_canonical_objects(artifacts, CART_MANDATE_DATA_KEY, CartMandate) -def _parse_age_verification_required(artifacts: list) -> bool: - """Returns True if the merchant indicated age verification is required.""" - for artifact in (artifacts or []): +async def request_identity_verification( + tool_context: ToolContext, + debug_mode: bool = False, +) -> str: + """Requests identity verification from the OneID Identity Provider. + + Sends an A2A request to OneID and returns the challenge text to present + to the user. Stores the task ID for use in complete_identity_verification. + + AP2 EXTENSION (OneID demo): Identity verification at intent-confirmation + time is outside the AP2 spec. The spec states the Cart Mandate should + contain "verifiable identities for the payer and payee" and be + "cryptographically signed by the user", but does not specify when or how + that credential is obtained. We obtain it here — before find_products — + so the credential can be reused when co-signing the Cart Mandate without + requiring a second authentication step. + See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + + Args: + tool_context: The ADK supplied tool context. + debug_mode: Whether the agent is in debug mode. + + Returns: + The challenge display text to show to the user. + """ + intent_mandate: IntentMandate = tool_context.state["intent_mandate"] + message = ( + A2aMessageBuilder() + .add_text("verify_identity") + .add_data("verification_requirements", { + "age_over_18": True, + # Scope the credential to the IntentMandate's lifetime so it does not + # outlive the transaction it was issued for. + "valid_until": intent_mandate.intent_expiry, + }) + .add_data("debug_mode", debug_mode) + .build() + ) + task = await oneid_identity_provider_client.send_a2a_message(message) + tool_context.state["oneid_task_id"] = task.id + + # Extract challenge text from the task status message. + if task.status and task.status.message: + for part in (task.status.message.parts or []): + data = getattr(part.root, "data", None) + if data and "challenge" in data: + return data["challenge"].get("display_text", "") + + return ( + "OneID requires you to verify your identity. " + "Type 'confirm' to simulate bank authentication." + ) + + +async def complete_identity_verification( + confirmation: str, + tool_context: ToolContext, + debug_mode: bool = False, +) -> str: + """Completes identity verification with the user's confirmation. + + Re-sends the identity verification request to OneID with the user's + confirmation (challenge response) using the existing task ID, mirroring + the OTP challenge-response pattern. + + The resulting credential is stored in state and will be attached to the + Cart Mandate when the user confirms their cart (via update_cart). + + AP2 EXTENSION (OneID demo): Storing the credential here and reusing it + at Cart Mandate co-signing time (rather than requiring re-authentication) + is a convenience not described in the AP2 spec. The credential is scoped + to the IntentMandate's expiry so it cannot be reused beyond the session. + See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + + Args: + confirmation: The user's confirmation response. + tool_context: The ADK supplied tool context. + debug_mode: Whether the agent is in debug mode. + + Returns: + Human-readable verification result including the verified claims. + """ + oneid_task_id = tool_context.state.get("oneid_task_id") + if not oneid_task_id: + raise RuntimeError( + "No OneID task ID found. Call request_identity_verification first." + ) + + intent_mandate: IntentMandate = tool_context.state["intent_mandate"] + message = ( + A2aMessageBuilder() + .set_task_id(oneid_task_id) + .add_text("verify_identity") + .add_data("verification_requirements", { + "age_over_18": True, + "valid_until": intent_mandate.intent_expiry, + }) + .add_data("challenge_response", confirmation) + .add_data("debug_mode", debug_mode) + .build() + ) + task = await oneid_identity_provider_client.send_a2a_message(message) + + # Extract identity credential from artifacts. + identity_credential = None + for artifact in (task.artifacts or []): for part in (artifact.parts or []): data = getattr(part.root, "data", None) - if data and "age_verification_required" in data: - return bool(data["age_verification_required"]) - return False + if data and "oneid.IdentityCredential" in data: + identity_credential = data["oneid.IdentityCredential"] + break + + if identity_credential: + tool_context.state["identity_credential"] = identity_credential + + # AP2 EXTENSION: Co-sign the IntentMandate with the verified identity + # credential. This binds the user's identity to the earliest + # user-authorised artefact in the flow, regardless of whether this is + # a HITL or HOOTL transaction. + # See: https://ap2-protocol.org/specification/#412-the-intent-mandate + intent_mandate: IntentMandate = tool_context.state["intent_mandate"] + intent_mandate.identity_credential = ( + base64.urlsafe_b64encode( + json.dumps(identity_credential, separators=(",", ":")).encode() + ).decode().rstrip("=") + ) + tool_context.state["intent_mandate"] = intent_mandate + + subject = identity_credential.get("credential_subject", {}) + scopes = ", ".join(subject.get("verified_scopes", [])) + return ( + f"Identity verification successful. IntentMandate co-signed with" + f" verified OneID credential. " + f"Verified scopes: {scopes}. " + f"Framework: {subject.get('framework')}, " + f"assurance level: {subject.get('assurance_level')}. " + f"Issuer: {identity_credential.get('issuer')}." + ) + + return "Identity verification failed or returned no credential." def _collect_risk_data(tool_context: ToolContext) -> dict: diff --git a/samples/python/src/roles/shopping_agent/tools.py b/samples/python/src/roles/shopping_agent/tools.py index 01c1f6ba..0fda12a2 100644 --- a/samples/python/src/roles/shopping_agent/tools.py +++ b/samples/python/src/roles/shopping_agent/tools.py @@ -18,8 +18,10 @@ shopping and purchasing process, such as updating a cart or initiating payment. """ +import base64 from datetime import datetime from datetime import timezone +import json import os import uuid @@ -28,7 +30,6 @@ from .remote_agents import credentials_provider_client from .remote_agents import merchant_agent_client -from .remote_agents import oneid_identity_provider_client from ap2.types.contact_picker import ContactAddress from ap2.types.mandate import CART_MANDATE_DATA_KEY from ap2.types.mandate import CartMandate @@ -83,105 +84,58 @@ async def update_cart( return updated_cart_mandate -async def request_identity_verification( - tool_context: ToolContext, - debug_mode: bool = False, -) -> str: - """Requests age/identity verification from the OneID Identity Provider. - - Sends an A2A request to OneID and returns the challenge text to present - to the user. Stores the task ID for use in complete_identity_verification. - - Args: - tool_context: The ADK supplied tool context. - debug_mode: Whether the agent is in debug mode. - - Returns: - The challenge display text to show to the user. - """ - message = ( - A2aMessageBuilder() - .set_context_id(tool_context.state["shopping_context_id"]) - .add_text("verify_identity") - .add_data("verification_requirements", {"age_over_18": True}) - .add_data("debug_mode", debug_mode) - .build() - ) - task = await oneid_identity_provider_client.send_a2a_message(message) - tool_context.state["oneid_task_id"] = task.id +def cosign_cart_mandate(tool_context: ToolContext) -> str: + """Attaches the verified OneID identity credential to the current CartMandate. - # Extract challenge text from the task status message. - if task.status and task.status.message: - for part in (task.status.message.parts or []): - data = getattr(part.root, "data", None) - if data and "challenge" in data: - return data["challenge"].get("display_text", "") + This is the explicit co-signing step that binds the user's verified identity + to the merchant's signed cart offer. The CartMandate already carries the + merchant's authorization (merchant_authorization field); this step adds the + user-side identity (identity_credential field). - return ( - "OneID requires you to verify your age. " - "Type 'confirm' to simulate bank authentication." - ) + Must be called after update_cart (which fetches the final merchant-signed + CartMandate including shipping) and before sign_mandates_on_user_device. - -async def complete_identity_verification( - confirmation: str, - tool_context: ToolContext, - debug_mode: bool = False, -) -> str: - """Completes identity verification with the user's confirmation. - - Re-sends the identity verification request to OneID with the user's - confirmation (challenge response) using the existing task ID, mirroring - the OTP challenge-response pattern. + AP2 EXTENSION (OneID demo): The AP2 spec states the Cart Mandate should + contain "verifiable identities for the payer and payee" and be + "cryptographically signed by the user, binding their identity and + authorization to a specific transaction", but defines no field for the + user-side identity. We populate the `identity_credential` extension field + with the credential obtained at intent-confirmation time. + See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate Args: - confirmation: The user's confirmation response. tool_context: The ADK supplied tool context. - debug_mode: Whether the agent is in debug mode. Returns: - Human-readable verification result including the verified claims. + A confirmation message describing the co-signing result. """ - oneid_task_id = tool_context.state.get("oneid_task_id") - if not oneid_task_id: - raise RuntimeError( - "No OneID task ID found. Call request_identity_verification first." - ) + cart_mandate: CartMandate = tool_context.state.get("cart_mandate") + if not cart_mandate: + raise RuntimeError("No cart mandate found in state. Call update_cart first.") - message = ( - A2aMessageBuilder() - .set_context_id(tool_context.state["shopping_context_id"]) - .set_task_id(oneid_task_id) - .add_text("verify_identity") - .add_data("verification_requirements", {"age_over_18": True}) - .add_data("challenge_response", confirmation) - .add_data("debug_mode", debug_mode) - .build() - ) - task = await oneid_identity_provider_client.send_a2a_message(message) - - # Extract identity credential from artifacts. - identity_credential = None - for artifact in (task.artifacts or []): - for part in (artifact.parts or []): - data = getattr(part.root, "data", None) - if data and "oneid.IdentityCredential" in data: - identity_credential = data["oneid.IdentityCredential"] - break - - if identity_credential: - tool_context.state["identity_credential"] = identity_credential - subject = identity_credential.get("credential_subject", {}) + identity_credential = tool_context.state.get("identity_credential") + if not identity_credential: return ( - f"Identity verification successful. " - f"Verified: age_over_18={subject.get('age_over_18')}, " - f"bank={subject.get('bank')}, " - f"framework={subject.get('framework')}, " - f"assurance_level={subject.get('assurance_level')}. " - f"Issuer: {identity_credential.get('issuer')}." + "No OneID identity credential found in state — CartMandate not" + " co-signed. Identity verification may not have been completed." ) - return "Identity verification failed or returned no credential." + cart_mandate.identity_credential = ( + base64.urlsafe_b64encode( + json.dumps(identity_credential, separators=(",", ":")).encode() + ).decode().rstrip("=") + ) + tool_context.state["cart_mandate"] = cart_mandate + + subject = identity_credential.get("credential_subject", {}) + scopes = ", ".join(subject.get("verified_scopes", [])) + return ( + f"CartMandate co-signed with verified OneID identity. " + f"Verified scopes: {scopes}. " + f"Framework: {subject.get('framework')}, " + f"assurance level: {subject.get('assurance_level')}. " + f"The merchant's cart offer is now bound to the user's verified identity." + ) async def initiate_payment(tool_context: ToolContext, debug_mode: bool = False): @@ -209,16 +163,12 @@ async def initiate_payment(tool_context: ToolContext, debug_mode: bool = False): .add_data("risk_data", risk_data) .add_data("shopping_agent_id", "trusted_shopping_agent") .add_data("debug_mode", debug_mode) + .build() ) - # Include identity credential as evidence if age verification was performed. - identity_credential = tool_context.state.get("identity_credential") - if identity_credential: - outgoing_message_builder.add_data( - "oneid.IdentityCredential", identity_credential - ) - task = await merchant_agent_client.send_a2a_message( - outgoing_message_builder.build() - ) + # Note: the OneID identity credential is embedded in payment_mandate + # .user_authorization by sign_mandates_on_user_device — it travels with + # the mandate itself rather than as a separate data part. + task = await merchant_agent_client.send_a2a_message(outgoing_message_builder) store_receipt_if_present(task, tool_context) tool_context.state["initiate_payment_task_id"] = task.id return task.status @@ -337,15 +287,17 @@ def sign_mandates_on_user_device(tool_context: ToolContext) -> str: cryptographically signed with the user's private key. Note: This is a placeholder implementation. It does not perform any actual - cryptographic operations. It simulates the creation of a signature by - concatenating the mandate hashes. + cryptographic operations. It builds a base64url-encoded JSON payload binding + the mandate hashes to any OneID identity credential that was obtained earlier + in the flow. This mirrors the sd-jwt-vc structure described in the AP2 spec's + user_authorization field. Args: tool_context: The context object used for state management. It is expected to contain the `payment_mandate` and `cart_mandate`. Returns: - A string representing the simulated user authorization signature (JWT). + A string representing the simulated user authorization (base64url JWT). """ payment_mandate: PaymentMandate = tool_context.state["payment_mandate"] cart_mandate: CartMandate = tool_context.state["cart_mandate"] @@ -353,11 +305,27 @@ def sign_mandates_on_user_device(tool_context: ToolContext) -> str: payment_mandate_hash = _generate_payment_mandate_hash( payment_mandate.payment_mandate_contents ) - # A JWT containing the user's digital signature to authorize the transaction. - # The payload uses hashes to bind the signature to the specific cart and - # payment details, and includes a nonce to prevent replay attacks. + + # Build the authorization payload binding mandate hashes to identity. + # In production this would be a signed sd-jwt-vc from the user's secure + # device. Here we encode a structured payload to demonstrate the concept. + authorization_payload = { + "transaction_data": { + "cart_mandate_hash": cart_mandate_hash, + "payment_mandate_hash": payment_mandate_hash, + }, + } + + identity_credential = tool_context.state.get("identity_credential") + if identity_credential: + # Co-sign: bind the OneID identity credential to this specific transaction. + # The credential asserts verified identity (age, bank, framework) and is + # now inseparable from the mandate hashes it signs over. + authorization_payload["identity_credential"] = identity_credential + + payload_bytes = json.dumps(authorization_payload, separators=(",", ":")).encode() payment_mandate.user_authorization = ( - cart_mandate_hash + "_" + payment_mandate_hash + base64.urlsafe_b64encode(payload_bytes).decode().rstrip("=") ) tool_context.state["signed_payment_mandate"] = payment_mandate return payment_mandate.user_authorization diff --git a/src/ap2/types/mandate.py b/src/ap2/types/mandate.py index c5506689..d871062d 100644 --- a/src/ap2/types/mandate.py +++ b/src/ap2/types/mandate.py @@ -75,6 +75,24 @@ class IntentMandate(BaseModel): description="When the intent mandate expires, in ISO 8601 format.", ) + # AP2 EXTENSION (OneID demo): The AP2 spec describes the IntentMandate as + # "cryptographically signed by the user" for human-not-present flows + # (Section 4.1.2) but defines no field for a user-side identity credential + # on either flow variant. We attach the OneID credential here at + # intent-confirmation time — co-signing regardless of HITL/HOOTL — because + # the agent cannot reliably distinguish the two flows at that moment, and + # binding identity to the earliest user-authorised artefact is the stronger + # security posture in either case. + # See: https://ap2-protocol.org/specification/#412-the-intent-mandate + identity_credential: Optional[str] = Field( + None, + description=( + "AP2 EXTENSION: A base64url-encoded JWT Verifiable Credential issued" + " by OneID, binding the verified identity of the user to this" + " IntentMandate at the point of user confirmation." + ), + ) + class CartContents(BaseModel): """The detailed contents of a cart. @@ -111,6 +129,30 @@ class CartMandate(BaseModel): """ contents: CartContents = Field(..., description="The contents of the cart.") + + # AP2 EXTENSION (OneID demo): The AP2 spec states the Cart Mandate contains + # "verifiable identities for the payer and payee" and is "cryptographically + # signed by the user, binding their identity and authorization to a specific + # transaction", but defines no field for user-side identity. + # See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + # + # This field carries the base64url-encoded OneID JWT Verifiable Credential, + # obtained when the user verified their identity at intent-confirmation time. + # The credential is scoped to the IntentMandate's expiry (intent_expiry) and + # is reused here without re-authentication. + # + # In a future AP2 revision, a field such as `payer_identity_credential` or + # `user_authorization` on CartMandate would be the appropriate home for this. + # See the AP2 roadmap: https://ap2-protocol.org/specification/#roadmap + identity_credential: Optional[str] = Field( + None, + description=( + "AP2 EXTENSION: A base64url-encoded JWT Verifiable Credential issued" + " by OneID, attesting to the verified identity of the user who" + " confirmed this cart. Scoped to the IntentMandate expiry." + ), + ) + merchant_authorization: Optional[str] = Field( None, description=(""" A base64url-encoded JSON Web Token (JWT) that digitally From 1423e793065f186c9b7fc0930540288097324902 Mon Sep 17 00:00:00 2001 From: James Weaver Date: Wed, 15 Apr 2026 14:04:21 +0100 Subject: [PATCH 03/14] pass identity credential into payment mandate --- .../merchant_payment_processor_agent/tools.py | 78 +++++++++++++++++-- .../python/src/roles/shopping_agent/agent.py | 15 ++-- .../python/src/roles/shopping_agent/tools.py | 11 ++- 3 files changed, 89 insertions(+), 15 deletions(-) diff --git a/samples/python/src/roles/merchant_payment_processor_agent/tools.py b/samples/python/src/roles/merchant_payment_processor_agent/tools.py index d09ce6bb..cc87656c 100644 --- a/samples/python/src/roles/merchant_payment_processor_agent/tools.py +++ b/samples/python/src/roles/merchant_payment_processor_agent/tools.py @@ -18,8 +18,10 @@ shopping and purchasing process. """ +import base64 from datetime import datetime from datetime import timezone +import json import logging import os from typing import Any @@ -93,8 +95,10 @@ async def _handle_payment_mandate( debug_mode: Whether the agent is in debug mode. payment_method: The payment method to use (e.g., 'CARD', 'x402'). """ + identity_assertion = _extract_identity_assertion(payment_mandate) + if current_task is None: - await _raise_challenge(updater) + await _raise_challenge(updater, identity_assertion=identity_assertion) return if current_task.status.state == TaskState.input_required: @@ -110,6 +114,7 @@ async def _handle_payment_mandate( async def _raise_challenge( updater: TaskUpdater, + identity_assertion: dict | None = None, ) -> None: """Raises a transaction challenge. @@ -117,9 +122,48 @@ async def _raise_challenge( have an issuer in the demo, so we raise the challenge here. For concreteness, we are using an OTP challenge in this sample. + If a OneID identity assertion is present in the PaymentMandate, it is + surfaced here to show what the payment processor / issuer can see and + how they might use it in a real authorisation decision. + Args: updater: The task updater. + identity_assertion: Optional OneID identity credential extracted from + PaymentMandate.user_authorization, if present. """ + parts = [] + + if identity_assertion: + subject = identity_assertion.get("credential_subject", {}) + assurance = subject.get("assurance_level", "") + framework = subject.get("framework", "") + # Assess whether the trust signal is sufficient for frictionless auth. + # In a real bilateral agreement, the issuer's risk engine would make + # this determination. Here we simulate the decision criteria. + frictionless_eligible = assurance == "high" and framework == "UK_DIATF" + frictionless_note = ( + "This transaction carries a verified identity credential with high " + "assurance under UK_DIATF. Under a bilateral agreement with OneID, " + "an issuer COULD choose to approve this transaction frictionlessly " + "without an OTP challenge. In this demo, standard OTP applies." + if frictionless_eligible + else ( + "No OneID credential with sufficient assurance was found. " + "Standard OTP challenge required." + ) + ) + identity_data = { + "issuer": identity_assertion.get("issuer"), + "verified_scopes": subject.get("verified_scopes", []), + "assurance_level": assurance, + "framework": framework, + "frictionless_eligible": frictionless_eligible, + "frictionless_note": frictionless_note, + } + parts.append( + Part(root=DataPart(data={"oneid.IdentityAssertion": identity_data})) + ) + challenge_data = { "type": "otp", "display_text": ( @@ -129,13 +173,12 @@ async def _raise_challenge( "(Demo only hint: the code is 123)" ), } - text_part = TextPart( + parts.append(Part(root=TextPart( text="Please provide the challenge response to complete the payment." - ) - data_part = DataPart(data={"challenge": challenge_data}) - message = updater.new_agent_message( - parts=[Part(root=text_part), Part(root=data_part)] - ) + ))) + parts.append(Part(root=DataPart(data={"challenge": challenge_data}))) + + message = updater.new_agent_message(parts=parts) await updater.requires_input(message=message) @@ -373,6 +416,27 @@ async def _send_payment_receipt_to_credentials_provider( await credentials_provider.send_a2a_message(message_builder.build()) +def _extract_identity_assertion(payment_mandate: PaymentMandate) -> dict | None: + """Extracts the OneID identity credential from PaymentMandate.user_authorization. + + The user_authorization field carries a base64url-encoded JSON payload + produced by sign_mandates_on_user_device, binding the mandate hashes to + any OneID identity credential obtained during the shopping session. + + Returns the identity_credential dict if present, or None. + """ + user_auth = payment_mandate.user_authorization + if not user_auth: + return None + try: + # Restore stripped base64 padding before decoding. + padded = user_auth + "=" * (4 - len(user_auth) % 4) + payload = json.loads(base64.urlsafe_b64decode(padded)) + return payload.get("identity_credential") + except Exception: + return None + + def _create_text_parts(*texts: str) -> list[Part]: """Helper to create text parts.""" return [Part(root=TextPart(text=text)) for text in texts] diff --git a/samples/python/src/roles/shopping_agent/agent.py b/samples/python/src/roles/shopping_agent/agent.py index 6752b0f0..c8366606 100644 --- a/samples/python/src/roles/shopping_agent/agent.py +++ b/samples/python/src/roles/shopping_agent/agent.py @@ -89,11 +89,16 @@ a. `sign_mandates_on_user_device` b. `send_signed_payment_mandate_to_credentials_provider` 12. Initiate the payment by calling the `initiate_payment` tool. - 13. If prompted for an OTP, relay the OTP request to the user. - Do not ask the user for anything other than the OTP request. - Once you have an challenge response, display the display_text - from it and then call the `initiate_payment_with_otp` - tool to retry the payment. Surface the result to the user. + 13. If prompted for an OTP, first check whether an + `oneid.IdentityAssertion` DataPart was returned alongside the + challenge. If present, display it to the user BEFORE showing + the OTP prompt — format it clearly as what the payment processor + received, showing: verified scopes, assurance level, framework, + and the frictionless_note (the processor's hypothetical assessment + of whether it COULD have approved without a challenge). Then relay + the OTP request to the user. Do not ask the user for anything + other than the OTP code. Once you have a challenge response, call + `initiate_payment_with_otp`. Surface the result to the user. 14. If the response is a success or confirmation, create a block of text titled 'Payment Receipt'. Ensure its contents includes price, shipping, tax and total price. In a second block, show the diff --git a/samples/python/src/roles/shopping_agent/tools.py b/samples/python/src/roles/shopping_agent/tools.py index 0fda12a2..587f31ae 100644 --- a/samples/python/src/roles/shopping_agent/tools.py +++ b/samples/python/src/roles/shopping_agent/tools.py @@ -318,9 +318,14 @@ def sign_mandates_on_user_device(tool_context: ToolContext) -> str: identity_credential = tool_context.state.get("identity_credential") if identity_credential: - # Co-sign: bind the OneID identity credential to this specific transaction. - # The credential asserts verified identity (age, bank, framework) and is - # now inseparable from the mandate hashes it signs over. + # AP2 EXTENSION (OneID demo): The AP2 spec defines user_authorization as + # a verifiable presentation containing transaction_data (hashes of the + # CartMandate and PaymentMandateContents). It does not define a field for + # embedding a third-party identity credential within that payload. + # We add `identity_credential` here to bind the OneID VC to the mandate + # hashes, making the verified identity assertion cryptographically + # inseparable from the transaction authorization. + # See: https://ap2-protocol.org/specification/#413-the-payment-mandate-for-ai-agent-visibility-to-payments-ecosystem authorization_payload["identity_credential"] = identity_credential payload_bytes = json.dumps(authorization_payload, separators=(",", ":")).encode() From 3605803e5c3737569e9208b83f672909a34bc3b0 Mon Sep 17 00:00:00 2001 From: James Weaver Date: Wed, 15 Apr 2026 18:04:22 +0100 Subject: [PATCH 04/14] remove signing of intentmandate when in HITL flow --- .../roles/oneid_identity_provider/tools.py | 6 +- .../python/src/roles/shopping_agent/agent.py | 29 ++-- .../shopping_agent/subagents/shopper/agent.py | 17 +- .../shopping_agent/subagents/shopper/tools.py | 145 ------------------ .../python/src/roles/shopping_agent/tools.py | 127 +++++++++++++++ src/ap2/types/mandate.py | 22 +-- 6 files changed, 162 insertions(+), 184 deletions(-) diff --git a/samples/python/src/roles/oneid_identity_provider/tools.py b/samples/python/src/roles/oneid_identity_provider/tools.py index aaf9187c..778fd1b7 100644 --- a/samples/python/src/roles/oneid_identity_provider/tools.py +++ b/samples/python/src/roles/oneid_identity_provider/tools.py @@ -55,8 +55,8 @@ async def handle_verify_identity( verification_requirements = ( message_utils.find_data_part("verification_requirements", data_parts) or {} ) - # Scope the credential to the IntentMandate's expiry so it cannot be reused - # beyond the transaction session it was issued for. + # Scope the credential to the caller-supplied expiry (e.g. CartMandate or + # IntentMandate expiry) so it cannot be reused beyond the current session. valid_until = verification_requirements.get("valid_until") if current_task is None: @@ -140,7 +140,7 @@ async def _complete_identity_verification( }, } if valid_until: - # Bind the credential lifetime to the IntentMandate's expiry. + # Bind the credential lifetime to the session expiry supplied by the caller. credential["expiration_date"] = valid_until data_part = DataPart(data={IDENTITY_CREDENTIAL_DATA_KEY: credential}) await updater.add_artifact([Part(root=data_part)]) diff --git a/samples/python/src/roles/shopping_agent/agent.py b/samples/python/src/roles/shopping_agent/agent.py index c8366606..be9e3f3b 100644 --- a/samples/python/src/roles/shopping_agent/agent.py +++ b/samples/python/src/roles/shopping_agent/agent.py @@ -57,13 +57,18 @@ 4. Once you have the shipping address, call the `update_cart` tool to update the cart with the merchant. 4b. Display the updated cart to the user — show the item, shipping - address, and final total including shipping. Then ask: - "Your cart is ready. To complete this purchase as a verified - user, your confirmed identity from OneID will be bound to this - cart. Shall I co-sign it on your behalf?" - 4c. Once the user confirms, call the `cosign_cart_mandate` tool and - show the result to the user (e.g. "Done — your cart has been - co-signed with your verified OneID identity."). + address, and final total including shipping. Immediately (without + waiting for user input) call `request_identity_verification` and + append the challenge text it returns to the same response. + Do NOT ask "shall I proceed?" — the OneID challenge is the + single confirmation step. + 4c. Once the user responds (any non-empty reply is accepted in this + demo), call `complete_identity_verification` with their response. + Show the full verification result — verified scopes, framework, + and assurance level. + 4d. Call the `cosign_cart_mandate` tool and show the result to the + user (e.g. "Done — your cart has been co-signed with your + verified OneID identity."). 5. Delegate to the `payment_method_collector` agent to collect the user's payment method. 6. The `payment_method_collector` agent will return the user's @@ -81,8 +86,8 @@ payment method alias. Format it nicely. In a fourth block titled 'Identity Verification', show the verified scopes (e.g. age_over_18, openid), the framework (UK_DIATF), and assurance - level from the OneID credential, and note that both the - IntentMandate and CartMandate have been co-signed. + level from the OneID credential, and note that the CartMandate + has been co-signed with the verified OneID identity. 10. Confirm with the user they want to purchase the selected item using the selected form of payment. 11. When the user confirms purchase call the following tools in order: @@ -107,8 +112,8 @@ verification was performed, include a fourth block titled 'Identity Verification' showing: the verified scopes (e.g. age_over_18, openid), framework (UK_DIATF), assurance level, - and a note that the IntentMandate and CartMandate were both - co-signed with the OneID credential. + and a note that the CartMandate was co-signed with the verified + OneID identity. Scenario 2: The user first wants you to describe all the data passed between you, @@ -129,10 +134,12 @@ you can say 'I want to buy a pair of shoes'" """ % DEBUG_MODE_INSTRUCTIONS, tools=[ + tools.complete_identity_verification, tools.cosign_cart_mandate, tools.create_payment_mandate, tools.initiate_payment, tools.initiate_payment_with_otp, + tools.request_identity_verification, tools.send_signed_payment_mandate_to_credentials_provider, tools.sign_mandates_on_user_device, tools.update_cart, diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py index ff386eed..b264ba97 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py @@ -74,19 +74,8 @@ human-readable relative time (e.g., "in 1 hour", "in 2 days"). After the breakdown, leave a blank line and end with: "Shall I proceed?" - 4b. Once the user confirms the IntentMandate, call the - `request_identity_verification` tool. Present the challenge text it - returns to the user verbatim. This establishes who the real person - behind the transaction is, binding their verified identity to the - Cart Mandate that will be co-signed later in the flow. - 4c. Once the user responds (any non-empty reply is accepted in this demo), - call `complete_identity_verification` with their response. Show the - full verification result to the user, making clear that the - IntentMandate has been co-signed with their verified OneID identity - — include the verified scopes, framework, and assurance level. - Then proceed to step 5. - 5. Once identity verification is complete, use the 'find_products' tool. It - will return a list of `CartMandate` objects. + 5. Once the user confirms the IntentMandate, use the 'find_products' tool. + It will return a list of `CartMandate` objects. 6. For each CartMandate object in the list, create a visually distinct entry that includes the following details from the object: Item: Display the item_name clearly and in bold. @@ -112,8 +101,6 @@ """ % DEBUG_MODE_INSTRUCTIONS, tools=[ tools.create_intent_mandate, - tools.request_identity_verification, - tools.complete_identity_verification, tools.find_products, tools.update_chosen_cart_mandate, ], diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py index c4c17741..cee16eff 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py @@ -18,11 +18,9 @@ shopping and purchasing process. """ -import base64 from datetime import datetime from datetime import timedelta from datetime import timezone -import json from a2a.types import Artifact from google.adk.tools.tool_context import ToolContext @@ -34,7 +32,6 @@ from common.a2a_message_builder import A2aMessageBuilder from common.artifact_utils import find_canonical_objects from roles.shopping_agent.remote_agents import merchant_agent_client -from roles.shopping_agent.remote_agents import oneid_identity_provider_client def create_intent_mandate( @@ -136,148 +133,6 @@ def _parse_cart_mandates(artifacts: list[Artifact]) -> list[CartMandate]: return find_canonical_objects(artifacts, CART_MANDATE_DATA_KEY, CartMandate) -async def request_identity_verification( - tool_context: ToolContext, - debug_mode: bool = False, -) -> str: - """Requests identity verification from the OneID Identity Provider. - - Sends an A2A request to OneID and returns the challenge text to present - to the user. Stores the task ID for use in complete_identity_verification. - - AP2 EXTENSION (OneID demo): Identity verification at intent-confirmation - time is outside the AP2 spec. The spec states the Cart Mandate should - contain "verifiable identities for the payer and payee" and be - "cryptographically signed by the user", but does not specify when or how - that credential is obtained. We obtain it here — before find_products — - so the credential can be reused when co-signing the Cart Mandate without - requiring a second authentication step. - See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate - - Args: - tool_context: The ADK supplied tool context. - debug_mode: Whether the agent is in debug mode. - - Returns: - The challenge display text to show to the user. - """ - intent_mandate: IntentMandate = tool_context.state["intent_mandate"] - message = ( - A2aMessageBuilder() - .add_text("verify_identity") - .add_data("verification_requirements", { - "age_over_18": True, - # Scope the credential to the IntentMandate's lifetime so it does not - # outlive the transaction it was issued for. - "valid_until": intent_mandate.intent_expiry, - }) - .add_data("debug_mode", debug_mode) - .build() - ) - task = await oneid_identity_provider_client.send_a2a_message(message) - tool_context.state["oneid_task_id"] = task.id - - # Extract challenge text from the task status message. - if task.status and task.status.message: - for part in (task.status.message.parts or []): - data = getattr(part.root, "data", None) - if data and "challenge" in data: - return data["challenge"].get("display_text", "") - - return ( - "OneID requires you to verify your identity. " - "Type 'confirm' to simulate bank authentication." - ) - - -async def complete_identity_verification( - confirmation: str, - tool_context: ToolContext, - debug_mode: bool = False, -) -> str: - """Completes identity verification with the user's confirmation. - - Re-sends the identity verification request to OneID with the user's - confirmation (challenge response) using the existing task ID, mirroring - the OTP challenge-response pattern. - - The resulting credential is stored in state and will be attached to the - Cart Mandate when the user confirms their cart (via update_cart). - - AP2 EXTENSION (OneID demo): Storing the credential here and reusing it - at Cart Mandate co-signing time (rather than requiring re-authentication) - is a convenience not described in the AP2 spec. The credential is scoped - to the IntentMandate's expiry so it cannot be reused beyond the session. - See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate - - Args: - confirmation: The user's confirmation response. - tool_context: The ADK supplied tool context. - debug_mode: Whether the agent is in debug mode. - - Returns: - Human-readable verification result including the verified claims. - """ - oneid_task_id = tool_context.state.get("oneid_task_id") - if not oneid_task_id: - raise RuntimeError( - "No OneID task ID found. Call request_identity_verification first." - ) - - intent_mandate: IntentMandate = tool_context.state["intent_mandate"] - message = ( - A2aMessageBuilder() - .set_task_id(oneid_task_id) - .add_text("verify_identity") - .add_data("verification_requirements", { - "age_over_18": True, - "valid_until": intent_mandate.intent_expiry, - }) - .add_data("challenge_response", confirmation) - .add_data("debug_mode", debug_mode) - .build() - ) - task = await oneid_identity_provider_client.send_a2a_message(message) - - # Extract identity credential from artifacts. - identity_credential = None - for artifact in (task.artifacts or []): - for part in (artifact.parts or []): - data = getattr(part.root, "data", None) - if data and "oneid.IdentityCredential" in data: - identity_credential = data["oneid.IdentityCredential"] - break - - if identity_credential: - tool_context.state["identity_credential"] = identity_credential - - # AP2 EXTENSION: Co-sign the IntentMandate with the verified identity - # credential. This binds the user's identity to the earliest - # user-authorised artefact in the flow, regardless of whether this is - # a HITL or HOOTL transaction. - # See: https://ap2-protocol.org/specification/#412-the-intent-mandate - intent_mandate: IntentMandate = tool_context.state["intent_mandate"] - intent_mandate.identity_credential = ( - base64.urlsafe_b64encode( - json.dumps(identity_credential, separators=(",", ":")).encode() - ).decode().rstrip("=") - ) - tool_context.state["intent_mandate"] = intent_mandate - - subject = identity_credential.get("credential_subject", {}) - scopes = ", ".join(subject.get("verified_scopes", [])) - return ( - f"Identity verification successful. IntentMandate co-signed with" - f" verified OneID credential. " - f"Verified scopes: {scopes}. " - f"Framework: {subject.get('framework')}, " - f"assurance level: {subject.get('assurance_level')}. " - f"Issuer: {identity_credential.get('issuer')}." - ) - - return "Identity verification failed or returned no credential." - - def _collect_risk_data(tool_context: ToolContext) -> dict: """Creates a risk_data in the tool_context.""" # This is a fake risk data for demonstration purposes. diff --git a/samples/python/src/roles/shopping_agent/tools.py b/samples/python/src/roles/shopping_agent/tools.py index 587f31ae..cac903bb 100644 --- a/samples/python/src/roles/shopping_agent/tools.py +++ b/samples/python/src/roles/shopping_agent/tools.py @@ -30,6 +30,7 @@ from .remote_agents import credentials_provider_client from .remote_agents import merchant_agent_client +from .remote_agents import oneid_identity_provider_client from ap2.types.contact_picker import ContactAddress from ap2.types.mandate import CART_MANDATE_DATA_KEY from ap2.types.mandate import CartMandate @@ -84,6 +85,132 @@ async def update_cart( return updated_cart_mandate +async def request_identity_verification( + tool_context: ToolContext, + debug_mode: bool = False, +) -> str: + """Requests identity verification from the OneID Identity Provider. + + Sends an A2A request to OneID and returns the challenge text to present + to the user. Must be called after update_cart so the credential can be + scoped to the CartMandate's expiry window. + + Stores the OneID task ID in state for use by complete_identity_verification. + + AP2 EXTENSION (OneID demo): Identity verification at cart co-signing time + is outside the AP2 spec. The spec states the Cart Mandate should contain + "verifiable identities for the payer and payee" and be "cryptographically + signed by the user, binding their identity and authorization to a specific + transaction", but defines no field for user-side identity or when the + credential is obtained. + See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + + Args: + tool_context: The ADK supplied tool context. + debug_mode: Whether the agent is in debug mode. + + Returns: + The challenge display text to show to the user. + """ + cart_mandate: CartMandate = tool_context.state["cart_mandate"] + message = ( + A2aMessageBuilder() + .set_context_id(tool_context.state["shopping_context_id"]) + .add_text("verify_identity") + .add_data("verification_requirements", { + "age_over_18": True, + # Scope the credential to the CartMandate's expiry so it cannot be + # reused beyond the transaction it was issued for. + "valid_until": cart_mandate.contents.cart_expiry, + }) + .add_data("debug_mode", debug_mode) + .build() + ) + task = await oneid_identity_provider_client.send_a2a_message(message) + tool_context.state["oneid_task_id"] = task.id + + if task.status and task.status.message: + for part in (task.status.message.parts or []): + data = getattr(part.root, "data", None) + if data and "challenge" in data: + return data["challenge"].get("display_text", "") + + return ( + "OneID requires you to verify your identity. " + "Type 'confirm' to simulate completing verification." + ) + + +async def complete_identity_verification( + confirmation: str, + tool_context: ToolContext, + debug_mode: bool = False, +) -> str: + """Completes identity verification with the user's confirmation. + + Re-sends the verification request to OneID with the user's response using + the existing task ID, mirroring the OTP challenge-response pattern. Stores + the resulting credential in state for use by cosign_cart_mandate. + + AP2 EXTENSION (OneID demo): Storing the credential here and reusing it at + Cart Mandate co-signing time is not described in the AP2 spec. The + credential is scoped to the CartMandate's expiry so it cannot be reused + beyond this session. + See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + + Args: + confirmation: The user's confirmation response. + tool_context: The ADK supplied tool context. + debug_mode: Whether the agent is in debug mode. + + Returns: + Human-readable verification result including the verified claims. + """ + oneid_task_id = tool_context.state.get("oneid_task_id") + if not oneid_task_id: + raise RuntimeError( + "No OneID task ID found. Call request_identity_verification first." + ) + + cart_mandate: CartMandate = tool_context.state["cart_mandate"] + message = ( + A2aMessageBuilder() + .set_context_id(tool_context.state["shopping_context_id"]) + .set_task_id(oneid_task_id) + .add_text("verify_identity") + .add_data("verification_requirements", { + "age_over_18": True, + "valid_until": cart_mandate.contents.cart_expiry, + }) + .add_data("challenge_response", confirmation) + .add_data("debug_mode", debug_mode) + .build() + ) + task = await oneid_identity_provider_client.send_a2a_message(message) + + identity_credential = None + for artifact in (task.artifacts or []): + for part in (artifact.parts or []): + data = getattr(part.root, "data", None) + if data and "oneid.IdentityCredential" in data: + identity_credential = data["oneid.IdentityCredential"] + break + + if identity_credential: + tool_context.state["identity_credential"] = identity_credential + subject = identity_credential.get("credential_subject", {}) + scopes = ", ".join(subject.get("verified_scopes", [])) + return ( + f"Identity verification successful. " + f"Verified scopes: {scopes}. " + f"Framework: {subject.get('framework')}, " + f"assurance level: {subject.get('assurance_level')}. " + f"Issuer: {identity_credential.get('issuer')}." + ) + + return "Identity verification failed or returned no credential." + + def cosign_cart_mandate(tool_context: ToolContext) -> str: """Attaches the verified OneID identity credential to the current CartMandate. diff --git a/src/ap2/types/mandate.py b/src/ap2/types/mandate.py index d871062d..bb69af17 100644 --- a/src/ap2/types/mandate.py +++ b/src/ap2/types/mandate.py @@ -76,20 +76,22 @@ class IntentMandate(BaseModel): ) # AP2 EXTENSION (OneID demo): The AP2 spec describes the IntentMandate as - # "cryptographically signed by the user" for human-not-present flows - # (Section 4.1.2) but defines no field for a user-side identity credential - # on either flow variant. We attach the OneID credential here at - # intent-confirmation time — co-signing regardless of HITL/HOOTL — because - # the agent cannot reliably distinguish the two flows at that moment, and - # binding identity to the earliest user-authorised artefact is the stronger - # security posture in either case. + # "cryptographically signed by the user" for human-not-present flows and + # defines no field for a user-side identity credential (Section 4.1.2). + # In HOOTL flows this field carries the OneID JWT VC that co-signs the + # IntentMandate at the point the user delegates authority to the agent — + # binding their verified identity to the mandate that authorises autonomous + # action on their behalf. + # NOT used in HITL flows: in human-present flows the CartMandate is the + # authorisation artefact and the credential is attached there instead. # See: https://ap2-protocol.org/specification/#412-the-intent-mandate identity_credential: Optional[str] = Field( None, description=( - "AP2 EXTENSION: A base64url-encoded JWT Verifiable Credential issued" - " by OneID, binding the verified identity of the user to this" - " IntentMandate at the point of user confirmation." + "AP2 EXTENSION (HOOTL only): A base64url-encoded JWT Verifiable" + " Credential issued by OneID, binding the verified identity of the" + " user to this IntentMandate at the point they delegate authority" + " to the agent for autonomous execution." ), ) From 607ba79ba62a7e87b39db4f05c996f1fd7372242 Mon Sep 17 00:00:00 2001 From: James Weaver Date: Wed, 15 Apr 2026 18:21:44 +0100 Subject: [PATCH 05/14] age verification in HITL --- .../sub_agents/catalog_agent.py | 33 ++++++++++++++++ .../python/src/roles/shopping_agent/agent.py | 38 +++++++++++++------ .../shopping_agent/subagents/shopper/tools.py | 24 +++++++++++- 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py b/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py index a6bd4823..b9a66159 100644 --- a/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py +++ b/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py @@ -46,6 +46,24 @@ from common.system_utils import DEBUG_MODE_INSTRUCTIONS +# Keywords used to detect age-restricted items in LLM-generated product labels. +# This is a demo heuristic — in a production system the merchant's catalogue +# would carry explicit age-restriction metadata rather than relying on keyword +# matching. +# AP2 EXTENSION: The AP2 spec does not define a mechanism for merchants to +# signal age restrictions within a CartMandate or cart response. We add the +# `age_verification_required` DataPart as a non-spec extension so the shopping +# agent can trigger identity verification with the appropriate UX. +# See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate +_AGE_RESTRICTED_KEYWORDS = frozenset({ + "wine", "beer", "ale", "lager", "stout", "cider", + "whisky", "whiskey", "vodka", "gin", "rum", "brandy", + "champagne", "prosecco", "spirit", "liqueur", "bourbon", + "alcohol", "tobacco", "cigarette", "cigar", "vape", + "age 18+", "[age 18+ required]", +}) + + async def find_items_workflow( data_parts: list[dict[str, Any]], updater: TaskUpdater, @@ -64,6 +82,10 @@ async def find_items_workflow( You MUST exclude all branding from the PaymentItem `label` field. + If any item is typically age-restricted (alcohol, wine, beer, spirits, + tobacco, or cigarettes), you MUST append ' [Age 18+ required]' to that + item's label field. + %s """ % DEBUG_MODE_INSTRUCTIONS @@ -88,9 +110,20 @@ async def find_items_workflow( updater, os.environ.get("PAYMENT_METHOD", "CARD"), ) + + # Dual-layer age restriction detection: the LLM was instructed to append + # '[Age 18+ required]' to restricted item labels; we also verify here with + # a deterministic keyword check so the flag is reliable regardless of LLM + # compliance. Both checks run — the label provides user-visible context. + age_verification_required = any( + any(kw in item.label.lower() for kw in _AGE_RESTRICTED_KEYWORDS) + for item in items + ) + risk_data = _collect_risk_data(updater) updater.add_artifact([ Part(root=DataPart(data={"risk_data": risk_data})), + Part(root=DataPart(data={"age_verification_required": age_verification_required})), ]) await updater.complete() except ValidationError as e: diff --git a/samples/python/src/roles/shopping_agent/agent.py b/samples/python/src/roles/shopping_agent/agent.py index be9e3f3b..47689227 100644 --- a/samples/python/src/roles/shopping_agent/agent.py +++ b/samples/python/src/roles/shopping_agent/agent.py @@ -57,18 +57,28 @@ 4. Once you have the shipping address, call the `update_cart` tool to update the cart with the merchant. 4b. Display the updated cart to the user — show the item, shipping - address, and final total including shipping. Immediately (without - waiting for user input) call `request_identity_verification` and - append the challenge text it returns to the same response. - Do NOT ask "shall I proceed?" — the OneID challenge is the - single confirmation step. + address, and final total including shipping. + Check whether the shopper's response mentioned age verification + (look for "Age verification (18+) is required"): + - If age-restricted: include a clear note that this cart + contains age-restricted items and that OneID will verify the + user is 18+ before co-signing the cart mandate. + - If not age-restricted: note that OneID will verify the user's + identity and bind it to the cart mandate. + Immediately (without waiting for user input) call + `request_identity_verification` and append the challenge text + it returns to the same response. Do NOT ask "shall I proceed?" + — the OneID challenge is the single confirmation step. 4c. Once the user responds (any non-empty reply is accepted in this demo), call `complete_identity_verification` with their response. Show the full verification result — verified scopes, framework, - and assurance level. - 4d. Call the `cosign_cart_mandate` tool and show the result to the - user (e.g. "Done — your cart has been co-signed with your - verified OneID identity."). + and assurance level. If this was an age verification flow, + highlight the `age_over_18` scope prominently. + 4d. Call the `cosign_cart_mandate` tool and show the result: + - If age-restricted: "Done — your cart has been co-signed. + Age verification (18+) confirmed via OneID." + - If not: "Done — your cart has been co-signed with your + verified OneID identity." 5. Delegate to the `payment_method_collector` agent to collect the user's payment method. 6. The `payment_method_collector` agent will return the user's @@ -86,8 +96,11 @@ payment method alias. Format it nicely. In a fourth block titled 'Identity Verification', show the verified scopes (e.g. age_over_18, openid), the framework (UK_DIATF), and assurance - level from the OneID credential, and note that the CartMandate - has been co-signed with the verified OneID identity. + level from the OneID credential. If the cart contained + age-restricted items, note explicitly that age_over_18 was + verified as required for this purchase. Note that the + CartMandate has been co-signed with the verified OneID + identity. 10. Confirm with the user they want to purchase the selected item using the selected form of payment. 11. When the user confirms purchase call the following tools in order: @@ -113,7 +126,8 @@ 'Identity Verification' showing: the verified scopes (e.g. age_over_18, openid), framework (UK_DIATF), assurance level, and a note that the CartMandate was co-signed with the verified - OneID identity. + OneID identity. If the cart contained age-restricted items, + note explicitly that age_over_18 was verified as required. Scenario 2: The user first wants you to describe all the data passed between you, diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py index cee16eff..7d08314b 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py @@ -107,6 +107,8 @@ async def find_products( tool_context.state["shopping_context_id"] = task.context_id cart_mandates = _parse_cart_mandates(task.artifacts) tool_context.state["cart_mandates"] = cart_mandates + age_verification_required = _parse_age_verification_required(task.artifacts) + tool_context.state["age_verification_required"] = age_verification_required return cart_mandates @@ -124,7 +126,10 @@ def update_chosen_cart_mandate(cart_id: str, tool_context: ToolContext) -> str: ) if cart.contents.id == cart_id: tool_context.state["chosen_cart_id"] = cart_id - return f"CartMandate with ID {cart_id} selected." + age_note = "" + if tool_context.state.get("age_verification_required"): + age_note = " Age verification (18+) is required for items in this cart." + return f"CartMandate with ID {cart_id} selected.{age_note}" return f"CartMandate with ID {cart_id} not found." @@ -133,6 +138,23 @@ def _parse_cart_mandates(artifacts: list[Artifact]) -> list[CartMandate]: return find_canonical_objects(artifacts, CART_MANDATE_DATA_KEY, CartMandate) +def _parse_age_verification_required(artifacts: list[Artifact]) -> bool: + """Returns True if the merchant flagged any cart item as age-restricted. + + AP2 EXTENSION: `age_verification_required` is not a standard AP2 field. + It is emitted by the catalog agent as a demo extension to signal that the + shopping agent should trigger OneID age verification before co-signing the + CartMandate. + See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + """ + for artifact in (artifacts or []): + for part in (artifact.parts or []): + data = getattr(part.root, "data", None) + if data and "age_verification_required" in data: + return bool(data["age_verification_required"]) + return False + + def _collect_risk_data(tool_context: ToolContext) -> dict: """Creates a risk_data in the tool_context.""" # This is a fake risk data for demonstration purposes. From ca8520a426f9e3a1a8769eb563372671050deb5b Mon Sep 17 00:00:00 2001 From: James Weaver Date: Wed, 15 Apr 2026 18:41:40 +0100 Subject: [PATCH 06/14] disable thinking --- samples/python/src/roles/shopping_agent/agent.py | 4 +++- .../src/roles/shopping_agent/subagents/shopper/agent.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/samples/python/src/roles/shopping_agent/agent.py b/samples/python/src/roles/shopping_agent/agent.py index 47689227..90f68a30 100644 --- a/samples/python/src/roles/shopping_agent/agent.py +++ b/samples/python/src/roles/shopping_agent/agent.py @@ -19,7 +19,7 @@ 2. Help complete the purchase of their chosen items. The Google ADK powers this shopping agent, chosen for its simplicity and -efficiency in developing robust LLM agents. +efficiency in developing robust LLM agents. """ from . import tools @@ -34,6 +34,8 @@ max_retries=5, model="gemini-2.5-flash", name="root_agent", + # disable extended thinking — not necessary with explicit instructions and causes slow responses + generate_content_config={"thinking_config": {"thinking_budget": 0}}, instruction=""" You are a shopping agent responsible for helping users find and purchase products from merchants. diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py index b264ba97..fe04b0d6 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py @@ -33,6 +33,8 @@ model="gemini-2.5-flash", name="shopper", max_retries=5, + # disable extended thinking — not necessary with explicit instructions and causes slow responses + generate_content_config={"thinking_config": {"thinking_budget": 0}}, instruction=""" You are an agent responsible for helping the user shop for products. From 0e1d2f1186d8ff8322b6a46f440b724bd3da6f69 Mon Sep 17 00:00:00 2001 From: James Weaver Date: Wed, 15 Apr 2026 21:05:12 +0100 Subject: [PATCH 07/14] HOOTL branch from HITL flow - WIP also fixes thinking disabling --- .../python/src/roles/shopping_agent/agent.py | 88 ++++++++- .../shopping_agent/subagents/shopper/agent.py | 48 ++++- .../python/src/roles/shopping_agent/tools.py | 170 +++++++++++++++--- src/ap2/types/mandate.py | 18 ++ 4 files changed, 294 insertions(+), 30 deletions(-) diff --git a/samples/python/src/roles/shopping_agent/agent.py b/samples/python/src/roles/shopping_agent/agent.py index 90f68a30..0f483bea 100644 --- a/samples/python/src/roles/shopping_agent/agent.py +++ b/samples/python/src/roles/shopping_agent/agent.py @@ -22,6 +22,9 @@ efficiency in developing robust LLM agents. """ +from google.adk.planners import BuiltInPlanner +from google.genai import types + from . import tools from .subagents.payment_method_collector.agent import payment_method_collector from .subagents.shipping_address_collector.agent import shipping_address_collector @@ -35,7 +38,7 @@ model="gemini-2.5-flash", name="root_agent", # disable extended thinking — not necessary with explicit instructions and causes slow responses - generate_content_config={"thinking_config": {"thinking_budget": 0}}, + planner=BuiltInPlanner(thinking_config=types.ThinkingConfig(thinking_budget=0)), instruction=""" You are a shopping agent responsible for helping users find and purchase products from merchants. @@ -51,7 +54,11 @@ 1. Delegate to the `shopper` agent to collect the products the user is interested in purchasing. The `shopper` agent will return a message indicating if the chosen cart mandate is ready or not. - 2. Once a success message is received, delegate to the + 1a. Check the shopper's response: if it contains "HOOTL IntentMandate + confirmed", the user has pivoted to autonomous (HOOTL) mode during + product browsing — skip steps 2–14 and proceed directly to the + HOOTL Completion Steps in Scenario 3. + 2. Once a cart selection success message is received, delegate to the `shipping_address_collector` agent to collect the user's shipping address. 3. The shipping_address_collector agent will return the user's @@ -143,7 +150,80 @@ 2. Follow the instructions for Scenario 1 once the user confirms they want to start with their shopping prompt. - Scenario 3: + Scenario 3 — HOOTL (Autonomous / Delegated Purchase): + Trigger this scenario when the user explicitly asks the agent to act + autonomously — e.g., "autonomous", "HOOTL", "do it for me", + "without me", "set it up and go". + + AP2 spec Section 5.2 — "Human Not Present Transaction": in HOOTL the + IntentMandate is signed by the user INSTEAD OF the CartMandate. No + CartMandate is produced; the IntentMandate alone authorises autonomous + action on the user's behalf. + See: https://ap2-protocol.org/specification/#52-human-not-present-transaction + + 1. Delegate to the `shopper` agent, instructing it to collect intent + for HOOTL (autonomous) mode. The shopper will ask the user which + merchant to use, gather item description and any delegation + conditions (e.g., price ceiling), create an IntentMandate with + user_cart_confirmation_required=False, and return "HOOTL + IntentMandate confirmed." once the user confirms. + 2. Proceed to the HOOTL Completion Steps below. + + HOOTL Completion Steps + (Entered from Scenario 3, or from Scenario 1 step 1a when the shopper + returns "HOOTL IntentMandate confirmed" during a mid-session pivot.) + + HC1. Delegate to `shipping_address_collector` to collect the user's + shipping address. Display the returned address. + HC2. Delegate to `payment_method_collector` to collect the user's + payment method. Display the returned alias. + HC3. Call `set_hootl_payment_method` with the alias returned in HC2. + This records the pre-authorised payment method on the + IntentMandate before it is co-signed. + + AP2 EXTENSION: AP2 spec Section 4.1.2 lists "Chargeable Payment + Methods" as a component of the IntentMandate for HOOTL flows, + but v0.1 defines no field schema for it. Unlike HITL — where + payment lives in a separate PaymentMandate — in HOOTL the + payment authorisation must be IN the IntentMandate before the + user's identity is bound to it. + See: https://ap2-protocol.org/specification/#412-the-intent-mandate + + HC4. Show the user a block titled 'Delegation Summary' containing: + - Intent: natural language description from the IntentMandate + - Merchant: from the IntentMandate + - Shipping: the collected address + - Payment: the collected payment method alias + - Mode: Autonomous (no cart confirmation required) + Then immediately (without asking "shall I proceed?") call + `request_identity_verification` and append the challenge text + it returns. Explain: "OneID will now verify your identity and + bind it to your Intent Mandate — this is what authorises your + agent to act on your behalf without you being present." + HC5. Once the user responds (any non-empty reply is accepted in this + demo), call `complete_identity_verification` with their response. + Show the full verification result — verified scopes, framework, + and assurance level. + HC6. Call `cosign_intent_mandate`. Show the result. + HC7. Display a final summary with two blocks: + Block 1 — 'Intent Mandate': + - Item description + - Merchant + - Shipping address + - Payment method + - Mode: Autonomous (user_cart_confirmation_required=False) + - Expires: human-readable expiry + Block 2 — 'Identity Verification': + - Verified scopes (e.g., openid, age_over_18) + - Framework (UK_DIATF) + - Assurance level + - Note: IntentMandate co-signed with verified OneID identity + HC8. Close with: "Your Intent Mandate is signed and your identity + verified. In a production system, your agent would now contact + [merchant], find the best match for '[intent description]', and + complete the purchase — all without requiring you to be present." + + Scenario 4: The users ask you do to anything else. 1. Respond to the user with this message: "Hi, I'm your shopping assistant. How can I help you? For example, @@ -152,11 +232,13 @@ tools=[ tools.complete_identity_verification, tools.cosign_cart_mandate, + tools.cosign_intent_mandate, tools.create_payment_mandate, tools.initiate_payment, tools.initiate_payment_with_otp, tools.request_identity_verification, tools.send_signed_payment_mandate_to_credentials_provider, + tools.set_hootl_payment_method, tools.sign_mandates_on_user_device, tools.update_cart, ], diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py index fe04b0d6..438a79d2 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py @@ -24,6 +24,9 @@ This is just one of many possible approaches. """ +from google.adk.planners import BuiltInPlanner +from google.genai import types + from . import tools from common.retrying_llm_agent import RetryingLlmAgent from common.system_utils import DEBUG_MODE_INSTRUCTIONS @@ -34,13 +37,34 @@ name="shopper", max_retries=5, # disable extended thinking — not necessary with explicit instructions and causes slow responses - generate_content_config={"thinking_config": {"thinking_budget": 0}}, + planner=BuiltInPlanner(thinking_config=types.ThinkingConfig(thinking_budget=0)), instruction=""" You are an agent responsible for helping the user shop for products. %s - When asked to complete a task, follow these instructions: + When instructed by the root agent to collect intent for HOOTL + (autonomous) mode, follow these instructions instead: + H1. Explain to the user that in HOOTL (autonomous) mode the agent will + act on their behalf without requiring them to confirm the cart. A + specific merchant must be named upfront so the agent knows where to + shop. + H2. Ask which merchant they want to use for this autonomous purchase. + H3. Gather the item description and any other intent details one question + at a time (SKUs, refundability). + H4. Create the IntentMandate using `create_intent_mandate` with: + - user_cart_confirmation_required=False + - merchants=[the specified merchant] + H5. Present the IntentMandate to the user, formatted as in step 4 of the + standard flow below, but highlight that User Confirmation Required is + 'No — Autonomous mode'. End with: "By confirming, you are authorising + your agent to complete this purchase autonomously. Shall I proceed?" + H6. Once confirmed, return: "HOOTL IntentMandate confirmed. Merchant: + [merchant name]. user_cart_confirmation_required=False. Ready for + identity verification and signing." + + When asked to complete a standard (HITL) shopping task, follow these + instructions: 1. Find out what the user is interested in purchasing. 2. Ask clarifying questions one at a time to understand their needs fully. The shopping agent delegates responsibility for helping the user shop for @@ -100,6 +124,26 @@ 9. Monitor the tool's output. If the cart ID is not found, you must inform the user and prompt them to try again. If the selection is successful, signal a successful update and hand off the process to the root_agent. + + When the user responds to the product list with conditional or delegated + purchase intent — e.g., "buy this automatically", "get it if the price + drops below £X", "just handle it for me", "do it without me": + P1. Acknowledge their intent: confirm which item and the delegation + condition (e.g., "You'd like me to automatically buy [item] when the + price drops below £X."). + P2. If the price threshold or other condition is unclear, ask one + clarifying question to pin it down before continuing. + P3. Call `create_intent_mandate` with: + - user_cart_confirmation_required=False + - natural_language_description that includes both the item AND the + delegation condition (e.g., "Red Nike running shoes size 10 — + purchase when price is below £80") + - merchants=[the merchant from the currently presented products] + P4. Return exactly: "HOOTL IntentMandate confirmed. Merchant: [merchant + name]. user_cart_confirmation_required=False. Ready for delegation + setup." + Do NOT show the IntentMandate to the user or ask for further + confirmation — the root agent handles all subsequent HOOTL steps. """ % DEBUG_MODE_INSTRUCTIONS, tools=[ tools.create_intent_mandate, diff --git a/samples/python/src/roles/shopping_agent/tools.py b/samples/python/src/roles/shopping_agent/tools.py index cac903bb..74804806 100644 --- a/samples/python/src/roles/shopping_agent/tools.py +++ b/samples/python/src/roles/shopping_agent/tools.py @@ -34,6 +34,7 @@ from ap2.types.contact_picker import ContactAddress from ap2.types.mandate import CART_MANDATE_DATA_KEY from ap2.types.mandate import CartMandate +from ap2.types.mandate import IntentMandate from ap2.types.mandate import PAYMENT_MANDATE_DATA_KEY from ap2.types.mandate import PaymentMandate from ap2.types.mandate import PaymentMandateContents @@ -92,18 +93,20 @@ async def request_identity_verification( """Requests identity verification from the OneID Identity Provider. Sends an A2A request to OneID and returns the challenge text to present - to the user. Must be called after update_cart so the credential can be - scoped to the CartMandate's expiry window. + to the user. Works in both HITL and HOOTL flows: + - HITL: called after update_cart; credential is scoped to the CartMandate's + expiry (the transaction window). + - HOOTL: called after create_intent_mandate; credential is scoped to the + IntentMandate's expiry (the delegation window). Stores the OneID task ID in state for use by complete_identity_verification. - AP2 EXTENSION (OneID demo): Identity verification at cart co-signing time - is outside the AP2 spec. The spec states the Cart Mandate should contain - "verifiable identities for the payer and payee" and be "cryptographically - signed by the user, binding their identity and authorization to a specific - transaction", but defines no field for user-side identity or when the - credential is obtained. - See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + AP2 EXTENSION (OneID demo): The AP2 spec does not define when or how a + user-side identity credential is obtained for either HITL or HOOTL flows. + In HITL we obtain it at cart co-signing time; in HOOTL at intent-signing + time. Neither is described in the spec. + HITL ref: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + HOOTL ref: https://ap2-protocol.org/specification/#412-the-intent-mandate Args: tool_context: The ADK supplied tool context. @@ -112,16 +115,30 @@ async def request_identity_verification( Returns: The challenge display text to show to the user. """ - cart_mandate: CartMandate = tool_context.state["cart_mandate"] + # Scope the credential to whichever mandate is active in this session. + # In HITL the CartMandate is present after update_cart; in HOOTL only the + # IntentMandate exists (no cart has been selected yet). + cart_mandate = tool_context.state.get("cart_mandate") + intent_mandate = tool_context.state.get("intent_mandate") + if cart_mandate: + valid_until = cart_mandate.contents.cart_expiry + elif intent_mandate: + valid_until = intent_mandate.intent_expiry + else: + valid_until = None + + # shopping_context_id is set by find_products; in HOOTL (no find_products + # call) it may not be present, so we omit it rather than raise a KeyError. + context_id = tool_context.state.get("shopping_context_id") + builder = A2aMessageBuilder() + if context_id: + builder = builder.set_context_id(context_id) message = ( - A2aMessageBuilder() - .set_context_id(tool_context.state["shopping_context_id"]) + builder .add_text("verify_identity") .add_data("verification_requirements", { "age_over_18": True, - # Scope the credential to the CartMandate's expiry so it cannot be - # reused beyond the transaction it was issued for. - "valid_until": cart_mandate.contents.cart_expiry, + "valid_until": valid_until, }) .add_data("debug_mode", debug_mode) .build() @@ -150,13 +167,13 @@ async def complete_identity_verification( Re-sends the verification request to OneID with the user's response using the existing task ID, mirroring the OTP challenge-response pattern. Stores - the resulting credential in state for use by cosign_cart_mandate. + the resulting credential in state for use by cosign_cart_mandate (HITL) or + cosign_intent_mandate (HOOTL). - AP2 EXTENSION (OneID demo): Storing the credential here and reusing it at - Cart Mandate co-signing time is not described in the AP2 spec. The - credential is scoped to the CartMandate's expiry so it cannot be reused - beyond this session. - See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + AP2 EXTENSION (OneID demo): Storing the credential here for reuse at + mandate co-signing time is not described in the AP2 spec. + HITL ref: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + HOOTL ref: https://ap2-protocol.org/specification/#412-the-intent-mandate Args: confirmation: The user's confirmation response. @@ -172,15 +189,26 @@ async def complete_identity_verification( "No OneID task ID found. Call request_identity_verification first." ) - cart_mandate: CartMandate = tool_context.state["cart_mandate"] + cart_mandate = tool_context.state.get("cart_mandate") + intent_mandate = tool_context.state.get("intent_mandate") + if cart_mandate: + valid_until = cart_mandate.contents.cart_expiry + elif intent_mandate: + valid_until = intent_mandate.intent_expiry + else: + valid_until = None + + context_id = tool_context.state.get("shopping_context_id") + builder = A2aMessageBuilder() + if context_id: + builder = builder.set_context_id(context_id) message = ( - A2aMessageBuilder() - .set_context_id(tool_context.state["shopping_context_id"]) + builder .set_task_id(oneid_task_id) .add_text("verify_identity") .add_data("verification_requirements", { "age_over_18": True, - "valid_until": cart_mandate.contents.cart_expiry, + "valid_until": valid_until, }) .add_data("challenge_response", confirmation) .add_data("debug_mode", debug_mode) @@ -265,6 +293,98 @@ def cosign_cart_mandate(tool_context: ToolContext) -> str: ) +def set_hootl_payment_method( + payment_method_alias: str, + tool_context: ToolContext, +) -> str: + """Records the pre-authorised payment method on the current IntentMandate. + + In HOOTL flows the user authorises a payment method at delegation time so + the agent can pay without the user being present. Must be called after + the payment_method_collector returns and BEFORE cosign_intent_mandate, so + that the payment authorisation is part of what the user's identity is bound + to when the IntentMandate is co-signed. + + AP2 spec Section 4.1.2 lists "Chargeable Payment Methods" as a component + of the IntentMandate; v0.1 defines no field schema for it. We store the + credentials-provider alias as a placeholder for that data. + See: https://ap2-protocol.org/specification/#412-the-intent-mandate + + Args: + payment_method_alias: The alias returned by the payment_method_collector. + tool_context: The ADK supplied tool context. + + Returns: + Confirmation that the payment method was recorded on the IntentMandate. + """ + intent_mandate: IntentMandate = tool_context.state.get("intent_mandate") + if not intent_mandate: + raise RuntimeError( + "No IntentMandate in state. Call create_intent_mandate first." + ) + intent_mandate.payment_method_reference = payment_method_alias + tool_context.state["intent_mandate"] = intent_mandate + return ( + f"Payment method '{payment_method_alias}' recorded on IntentMandate." + f" It will be included in the co-signed mandate." + ) + + +def cosign_intent_mandate(tool_context: ToolContext) -> str: + """Attaches the verified OneID identity credential to the current IntentMandate. + + This is the HOOTL co-signing step that binds the user's verified identity to + their delegation authority. In HOOTL flows, the IntentMandate is what the + user signs "instead of" the CartMandate (AP2 spec Section 5.2), making it + the correct artefact to carry the identity credential. + + Must be called after complete_identity_verification. + + AP2 EXTENSION (OneID demo): The AP2 spec describes the IntentMandate as + "cryptographically signed by the user" for HOOTL flows (Section 4.1.2) but + defines no field for embedding a third-party identity credential within it. + We add `identity_credential` here so the user's verified identity is + inseparable from the mandate that authorises autonomous action on their + behalf. + See: https://ap2-protocol.org/specification/#412-the-intent-mandate + + Args: + tool_context: The ADK supplied tool context. + + Returns: + A confirmation message describing the co-signing result. + """ + intent_mandate: IntentMandate = tool_context.state.get("intent_mandate") + if not intent_mandate: + raise RuntimeError( + "No intent mandate found in state. Call create_intent_mandate first." + ) + + identity_credential = tool_context.state.get("identity_credential") + if not identity_credential: + return ( + "No OneID identity credential found in state — IntentMandate not" + " co-signed. Identity verification may not have been completed." + ) + + intent_mandate.identity_credential = ( + base64.urlsafe_b64encode( + json.dumps(identity_credential, separators=(",", ":")).encode() + ).decode().rstrip("=") + ) + tool_context.state["intent_mandate"] = intent_mandate + + subject = identity_credential.get("credential_subject", {}) + scopes = ", ".join(subject.get("verified_scopes", [])) + return ( + f"IntentMandate co-signed with verified OneID identity. " + f"Verified scopes: {scopes}. " + f"Framework: {subject.get('framework')}, " + f"assurance level: {subject.get('assurance_level')}. " + f"The user's delegation authority is now bound to their verified identity." + ) + + async def initiate_payment(tool_context: ToolContext, debug_mode: bool = False): """Initiates a payment using the payment mandate from state. diff --git a/src/ap2/types/mandate.py b/src/ap2/types/mandate.py index bb69af17..978c7cde 100644 --- a/src/ap2/types/mandate.py +++ b/src/ap2/types/mandate.py @@ -75,6 +75,24 @@ class IntentMandate(BaseModel): description="When the intent mandate expires, in ISO 8601 format.", ) + # AP2 spec Section 4.1.2 lists "Chargeable Payment Methods: A list or + # category of payment methods the user has authorized for the transaction" + # as a component of the IntentMandate, but v0.1 defines no schema for this + # field. We store the credentials-provider alias as a placeholder. + # Used in HOOTL flows only: the user pre-authorises a payment method at + # delegation time so the agent can pay without the user being present. It + # must be set BEFORE cosign_intent_mandate so it is included in what the + # user's identity is bound to. + # See: https://ap2-protocol.org/specification/#412-the-intent-mandate + payment_method_reference: Optional[str] = Field( + None, + description=( + "The alias or reference to the payment method the user has" + " pre-authorised for autonomous use. Maps to the 'Chargeable Payment" + " Methods' component of AP2 spec Section 4.1.2." + ), + ) + # AP2 EXTENSION (OneID demo): The AP2 spec describes the IntentMandate as # "cryptographically signed by the user" for human-not-present flows and # defines no field for a user-side identity credential (Section 4.1.2). From f6114c43f135f170ef7c606d4885bbc69124ffb6 Mon Sep 17 00:00:00 2001 From: James Weaver Date: Thu, 16 Apr 2026 12:09:52 +0100 Subject: [PATCH 08/14] HOOTL flow with identity verification --- .../roles/credentials_provider_agent/tools.py | 14 +- .../python/src/roles/shopping_agent/agent.py | 132 +++++++++--------- .../payment_method_collector/agent.py | 21 +++ .../payment_method_collector/tools.py | 31 ++-- .../shipping_address_collector/agent.py | 5 + .../shipping_address_collector/tools.py | 12 +- .../shopping_agent/subagents/shopper/agent.py | 37 ++--- 7 files changed, 152 insertions(+), 100 deletions(-) diff --git a/samples/python/src/roles/credentials_provider_agent/tools.py b/samples/python/src/roles/credentials_provider_agent/tools.py index e09b9e01..0acc94ad 100644 --- a/samples/python/src/roles/credentials_provider_agent/tools.py +++ b/samples/python/src/roles/credentials_provider_agent/tools.py @@ -88,7 +88,19 @@ async def handle_search_payment_methods( "user_email is required for search_payment_methods" ) if not method_data: - raise ValueError("method_data is required for search_payment_methods") + # HOOTL flow: no merchant CartMandate, so no method_data filter. + # Return all of the user's payment methods unfiltered. + payment_methods = account_manager.get_account_payment_methods(user_email) + if os.environ.get("PAYMENT_METHOD") == "x402": + payment_methods = [m for m in payment_methods if m.get("brand") == "x402"] + else: + payment_methods = [m for m in payment_methods if m.get("brand") != "x402"] + aliases = { + "payment_method_aliases": _get_payment_method_aliases(payment_methods) + } + await updater.add_artifact([Part(root=DataPart(data=aliases))]) + await updater.complete() + return merchant_method_data_list = [ PaymentMethodData.model_validate(data) for data in method_data diff --git a/samples/python/src/roles/shopping_agent/agent.py b/samples/python/src/roles/shopping_agent/agent.py index 0f483bea..8e6b9807 100644 --- a/samples/python/src/roles/shopping_agent/agent.py +++ b/samples/python/src/roles/shopping_agent/agent.py @@ -54,10 +54,6 @@ 1. Delegate to the `shopper` agent to collect the products the user is interested in purchasing. The `shopper` agent will return a message indicating if the chosen cart mandate is ready or not. - 1a. Check the shopper's response: if it contains "HOOTL IntentMandate - confirmed", the user has pivoted to autonomous (HOOTL) mode during - product browsing — skip steps 2–14 and proceed directly to the - HOOTL Completion Steps in Scenario 3. 2. Once a cart selection success message is received, delegate to the `shipping_address_collector` agent to collect the user's shipping address. @@ -151,77 +147,85 @@ want to start with their shopping prompt. Scenario 3 — HOOTL (Autonomous / Delegated Purchase): - Trigger this scenario when the user explicitly asks the agent to act - autonomously — e.g., "autonomous", "HOOTL", "do it for me", - "without me", "set it up and go". + Trigger when the user wants to delegate a future purchase to the + agent — e.g., "autonomous", "HOOTL", "buy it for me when...", + "as soon as it's available", "without me". This mirrors the AP2 spec + Section 5.2 example: "buy 2 tickets to this concert as soon as they + become available, make sure we're close to the main stage but don't + spend more than $1000." AP2 spec Section 5.2 — "Human Not Present Transaction": in HOOTL the IntentMandate is signed by the user INSTEAD OF the CartMandate. No CartMandate is produced; the IntentMandate alone authorises autonomous - action on the user's behalf. + action on the user's behalf. There is NO product browsing step — the + agent will find and buy the best match autonomously later. See: https://ap2-protocol.org/specification/#52-human-not-present-transaction - 1. Delegate to the `shopper` agent, instructing it to collect intent - for HOOTL (autonomous) mode. The shopper will ask the user which - merchant to use, gather item description and any delegation - conditions (e.g., price ceiling), create an IntentMandate with - user_cart_confirmation_required=False, and return "HOOTL - IntentMandate confirmed." once the user confirms. - 2. Proceed to the HOOTL Completion Steps below. - - HOOTL Completion Steps - (Entered from Scenario 3, or from Scenario 1 step 1a when the shopper - returns "HOOTL IntentMandate confirmed" during a mid-session pivot.) - - HC1. Delegate to `shipping_address_collector` to collect the user's - shipping address. Display the returned address. - HC2. Delegate to `payment_method_collector` to collect the user's - payment method. Display the returned alias. - HC3. Call `set_hootl_payment_method` with the alias returned in HC2. - This records the pre-authorised payment method on the - IntentMandate before it is co-signed. - - AP2 EXTENSION: AP2 spec Section 4.1.2 lists "Chargeable Payment - Methods" as a component of the IntentMandate for HOOTL flows, - but v0.1 defines no field schema for it. Unlike HITL — where - payment lives in a separate PaymentMandate — in HOOTL the - payment authorisation must be IN the IntentMandate before the - user's identity is bound to it. - See: https://ap2-protocol.org/specification/#412-the-intent-mandate - - HC4. Show the user a block titled 'Delegation Summary' containing: + 1. Tell the user: "I'll set up an autonomous delegation for you — + I need to collect your requirements, shipping address, and payment + method, then verify your identity to sign your Intent Mandate." + Then immediately delegate to the `shopper` agent with the explicit + instruction: "Collect intent for HOOTL autonomous mode." + 2. When the shopper's response contains "HOOTL IntentMandate + confirmed", do NOT output any text to the user. Your only action + is to immediately call transfer_to_agent with + `shipping_address_collector`. Do not wait for user input. + 3. Display the returned shipping address. + 4. Do NOT output any text to the user. Immediately call + transfer_to_agent with `payment_method_collector`. + 5. Display the returned payment method alias. + 6. Call `set_hootl_payment_method` with the alias from step 5. This + records the pre-authorised payment method on the IntentMandate + before it is co-signed. + + AP2 EXTENSION: AP2 spec Section 4.1.2 lists "Chargeable Payment + Methods" as a component of the IntentMandate for HOOTL flows, but + v0.1 defines no field schema for it. Unlike HITL — where payment + lives in a separate PaymentMandate — in HOOTL the payment + authorisation must be IN the IntentMandate before the user's + identity is bound to it. + See: https://ap2-protocol.org/specification/#412-the-intent-mandate + + 7. Call `request_identity_verification`. Then output a single + response with two clearly labelled sections: + + Section 1 — 'Delegation Summary': - Intent: natural language description from the IntentMandate - Merchant: from the IntentMandate - Shipping: the collected address - Payment: the collected payment method alias - Mode: Autonomous (no cart confirmation required) - Then immediately (without asking "shall I proceed?") call - `request_identity_verification` and append the challenge text - it returns. Explain: "OneID will now verify your identity and - bind it to your Intent Mandate — this is what authorises your - agent to act on your behalf without you being present." - HC5. Once the user responds (any non-empty reply is accepted in this - demo), call `complete_identity_verification` with their response. - Show the full verification result — verified scopes, framework, - and assurance level. - HC6. Call `cosign_intent_mandate`. Show the result. - HC7. Display a final summary with two blocks: - Block 1 — 'Intent Mandate': - - Item description - - Merchant - - Shipping address - - Payment method - - Mode: Autonomous (user_cart_confirmation_required=False) - - Expires: human-readable expiry - Block 2 — 'Identity Verification': - - Verified scopes (e.g., openid, age_over_18) - - Framework (UK_DIATF) - - Assurance level - - Note: IntentMandate co-signed with verified OneID identity - HC8. Close with: "Your Intent Mandate is signed and your identity - verified. In a production system, your agent would now contact - [merchant], find the best match for '[intent description]', and - complete the purchase — all without requiring you to be present." + + Section 2 — 'Identity Verification Required': + - Show the challenge text returned by + `request_identity_verification` + - Add: "In a production flow you would be redirected to your + bank to authenticate via OneID. For this demo, type + 'confirm' to proceed." + + End your response and wait for the user to reply. + 8. Once the user responds (any non-empty reply is accepted in this + demo), call `complete_identity_verification`. Show the full + verification result — verified scopes, framework, assurance level. + 9. Call `cosign_intent_mandate`. Show the result. + 10. Display a final summary with two blocks: + Block 1 — 'Intent Mandate': + - Item description + - Merchant + - Shipping address + - Payment method + - Mode: Autonomous (user_cart_confirmation_required=False) + - Expires: human-readable expiry + Block 2 — 'Identity Verification': + - Verified scopes (e.g., openid, age_over_18) + - Framework (UK_DIATF) + - Assurance level + - Note: IntentMandate co-signed with verified OneID identity + Then close with: "Your Intent Mandate is signed and your + identity verified. In a production system, your agent would now + contact [merchant], find the best match for '[intent + description]', and complete the purchase — all without requiring + you to be present." Scenario 4: The users ask you do to anything else. diff --git a/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/agent.py b/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/agent.py index 446f83fe..5ce1b671 100644 --- a/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/agent.py +++ b/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/agent.py @@ -25,6 +25,9 @@ provider, which is then sent to the merchant agent for payment. """ +from google.adk.planners import BuiltInPlanner +from google.genai import types + from . import tools from common.retrying_llm_agent import RetryingLlmAgent from common.system_utils import DEBUG_MODE_INSTRUCTIONS @@ -34,6 +37,8 @@ model="gemini-2.5-flash", name="payment_method_collector", max_retries=5, + # disable extended thinking — not necessary with explicit instructions and causes slow responses + planner=BuiltInPlanner(thinking_config=types.ThinkingConfig(thinking_budget=0)), instruction=""" You are an agent responsible for obtaining the user's payment method for a purchase. @@ -41,6 +46,22 @@ %s When asked to complete a task, follow these instructions: + + If the conversation indicates this is a HOOTL (autonomous) flow — for + example, the user requested autonomous mode, or there is no CartMandate + in the session — follow these steps instead of the numbered steps below: + A. Say: "To authorise your agent to pay on your behalf, please select + a payment method." + B. Call `get_payment_methods` with user_email="bugsbunny@gmail.com" + (the same demo account used for the shipping address). + Present the returned aliases to the user as a numbered list. + C. Ask the user to choose. Always present the list and wait for their + choice, even if there is only one option. + D. Once the user has chosen, transfer back to the root_agent passing + only the chosen alias as a plain string. Do NOT call + `get_payment_credential_token`. + + Otherwise (standard HITL flow): 1. Ensure a CartMandate object was provided to you. 2. Present a clear and organized summary of the cart to the user. The summary should be divided into two main sections: diff --git a/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/tools.py b/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/tools.py index f24fe572..1436ad6b 100644 --- a/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/tools.py +++ b/samples/python/src/roles/shopping_agent/subagents/payment_method_collector/tools.py @@ -18,6 +18,8 @@ shopping and purchasing process. """ +import uuid + from google.adk.tools.tool_context import ToolContext from ap2.types.payment_request import PAYMENT_METHOD_DATA_DATA_KEY @@ -32,27 +34,38 @@ async def get_payment_methods( ) -> list[str]: """Gets the user's payment methods from the credentials provider. - These will match the payment method on the cart being purchased. + In HITL flows, the CartMandate's method_data is used to filter results + to only methods accepted by the merchant. In HOOTL flows (no cart), + all of the user's payment methods are returned unfiltered. Args: user_email: Identifies the user's account tool_context: The ADK supplied tool context. Returns: - A dictionary of the user's applicable payment methods. + A list of the user's applicable payment method aliases. """ - cart_mandate = tool_context.state["cart_mandate"] + cart_mandate = tool_context.state.get("cart_mandate") + + # Reuse context_id set by find_products (HITL) or get_shipping_address + # (HOOTL). If neither has run yet, generate one now. + context_id = tool_context.state.get("shopping_context_id") + if not context_id: + context_id = str(uuid.uuid4()) + tool_context.state["shopping_context_id"] = context_id + message_builder = ( A2aMessageBuilder() - .set_context_id(tool_context.state["shopping_context_id"]) + .set_context_id(context_id) .add_text("Get a filtered list of the user's payment methods.") .add_data("user_email", user_email) ) - for method_data in cart_mandate.contents.payment_request.method_data: - message_builder.add_data( - PAYMENT_METHOD_DATA_DATA_KEY, - method_data.model_dump(), - ) + if cart_mandate: + for method_data in cart_mandate.contents.payment_request.method_data: + message_builder.add_data( + PAYMENT_METHOD_DATA_DATA_KEY, + method_data.model_dump(), + ) task = await credentials_provider_client.send_a2a_message( message_builder.build() ) diff --git a/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/agent.py b/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/agent.py index 0407b2b6..73f1eebb 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/agent.py +++ b/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/agent.py @@ -26,6 +26,9 @@ This is just one of many possible approaches. """ +from google.adk.planners import BuiltInPlanner +from google.genai import types + from . import tools from common.retrying_llm_agent import RetryingLlmAgent from common.system_utils import DEBUG_MODE_INSTRUCTIONS @@ -34,6 +37,8 @@ model="gemini-2.5-flash", name="shipping_address_collector", max_retries=5, + # disable extended thinking — not necessary with explicit instructions and causes slow responses + planner=BuiltInPlanner(thinking_config=types.ThinkingConfig(thinking_budget=0)), instruction=""" You are an agent responsible for obtaining the user's shipping address. diff --git a/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/tools.py b/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/tools.py index fd9e506c..26eb9e9f 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/tools.py +++ b/samples/python/src/roles/shopping_agent/subagents/shipping_address_collector/tools.py @@ -18,6 +18,8 @@ shopping and purchasing process. """ +import uuid + from a2a.types import Artifact from google.adk.tools.tool_context import ToolContext @@ -41,9 +43,17 @@ async def get_shipping_address( Returns: The user's shipping address. """ + # In HITL flows shopping_context_id is set by find_products. In HOOTL + # there is no find_products call, so we generate a fresh context ID and + # store it so all subsequent credential-provider calls share the same one. + context_id = tool_context.state.get("shopping_context_id") + if not context_id: + context_id = str(uuid.uuid4()) + tool_context.state["shopping_context_id"] = context_id + message = ( A2aMessageBuilder() - .set_context_id(tool_context.state["shopping_context_id"]) + .set_context_id(context_id) .add_text("Get the user's shipping address.") .add_data("user_email", user_email) .build() diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py index 438a79d2..88a8b973 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py @@ -50,18 +50,25 @@ specific merchant must be named upfront so the agent knows where to shop. H2. Ask which merchant they want to use for this autonomous purchase. - H3. Gather the item description and any other intent details one question - at a time (SKUs, refundability). + H3. Gather the delegation details one question at a time: + - What: item description and quantity (e.g., "2 concert tickets") + - Constraints: quality or location requirements (e.g., "close to main stage") + - Price ceiling: the maximum total the user is willing to spend + - Timing: when the agent should act (e.g., "as soon as available") + - Refundability: whether items must be refundable (optional) H4. Create the IntentMandate using `create_intent_mandate` with: - user_cart_confirmation_required=False - merchants=[the specified merchant] + - natural_language_description that captures the full delegation + intent including quantity, constraints, price ceiling, and timing + (e.g., "2 concert tickets — close to main stage — max $1000 — + purchase as soon as available") H5. Present the IntentMandate to the user, formatted as in step 4 of the standard flow below, but highlight that User Confirmation Required is 'No — Autonomous mode'. End with: "By confirming, you are authorising your agent to complete this purchase autonomously. Shall I proceed?" - H6. Once confirmed, return: "HOOTL IntentMandate confirmed. Merchant: - [merchant name]. user_cart_confirmation_required=False. Ready for - identity verification and signing." + H6. Once confirmed, immediately transfer back to the root_agent. Your + transfer message should be: "HOOTL IntentMandate confirmed." When asked to complete a standard (HITL) shopping task, follow these instructions: @@ -124,26 +131,6 @@ 9. Monitor the tool's output. If the cart ID is not found, you must inform the user and prompt them to try again. If the selection is successful, signal a successful update and hand off the process to the root_agent. - - When the user responds to the product list with conditional or delegated - purchase intent — e.g., "buy this automatically", "get it if the price - drops below £X", "just handle it for me", "do it without me": - P1. Acknowledge their intent: confirm which item and the delegation - condition (e.g., "You'd like me to automatically buy [item] when the - price drops below £X."). - P2. If the price threshold or other condition is unclear, ask one - clarifying question to pin it down before continuing. - P3. Call `create_intent_mandate` with: - - user_cart_confirmation_required=False - - natural_language_description that includes both the item AND the - delegation condition (e.g., "Red Nike running shoes size 10 — - purchase when price is below £80") - - merchants=[the merchant from the currently presented products] - P4. Return exactly: "HOOTL IntentMandate confirmed. Merchant: [merchant - name]. user_cart_confirmation_required=False. Ready for delegation - setup." - Do NOT show the IntentMandate to the user or ask for further - confirmation — the root agent handles all subsequent HOOTL steps. """ % DEBUG_MODE_INSTRUCTIONS, tools=[ tools.create_intent_mandate, From e51c481d47aa563a851b11d71a4dfd69bd3bee3e Mon Sep 17 00:00:00 2001 From: James Weaver Date: Thu, 16 Apr 2026 12:59:44 +0100 Subject: [PATCH 09/14] TODO notes for linking out to OneID, calling out spec extensions --- .../roles/credentials_provider_agent/tools.py | 10 ++- .../roles/oneid_identity_provider/tools.py | 76 ++++++++++++++++++- .../src/roles/shopping_agent/remote_agents.py | 4 + 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/samples/python/src/roles/credentials_provider_agent/tools.py b/samples/python/src/roles/credentials_provider_agent/tools.py index 0acc94ad..ad004e37 100644 --- a/samples/python/src/roles/credentials_provider_agent/tools.py +++ b/samples/python/src/roles/credentials_provider_agent/tools.py @@ -88,8 +88,14 @@ async def handle_search_payment_methods( "user_email is required for search_payment_methods" ) if not method_data: - # HOOTL flow: no merchant CartMandate, so no method_data filter. - # Return all of the user's payment methods unfiltered. + # AP2 EXTENSION (HOOTL flow): The AP2 spec does not define how a + # Credentials Provider should respond when no CartMandate is present + # (i.e. when the user is pre-authorising a payment method at delegation + # time rather than at checkout). We return all of the user's payment + # methods unfiltered so the shopping agent can present them for selection. + # In a production system a separate "list payment methods" endpoint would + # be more appropriate than overloading the merchant-filtered search. + # See: https://ap2-protocol.org/specification/#52-human-not-present-transaction payment_methods = account_manager.get_account_payment_methods(user_email) if os.environ.get("PAYMENT_METHOD") == "x402": payment_methods = [m for m in payment_methods if m.get("brand") == "x402"] diff --git a/samples/python/src/roles/oneid_identity_provider/tools.py b/samples/python/src/roles/oneid_identity_provider/tools.py index 778fd1b7..a05c3ee8 100644 --- a/samples/python/src/roles/oneid_identity_provider/tools.py +++ b/samples/python/src/roles/oneid_identity_provider/tools.py @@ -1,12 +1,24 @@ """Tools for the OneID Identity Provider Agent. +AP2 EXTENSION (OneID demo): The AP2 spec does not define an "Identity +Credential Provider" role or specify how identity verification is initiated, +completed, or communicated between agents via A2A. This entire module is a +demo extension illustrating how such a role *could* be integrated within the +AP2 agent-to-agent trust model. + +The verified credential returned here (oneid.IdentityCredential) is a +non-standard data key. In a future AP2 revision, a canonical mechanism for +conveying identity assertions between agents would make this key unnecessary. +See the AP2 roadmap: https://ap2-protocol.org/specification/#roadmap + OneID verifies user identity and returns a JWT Verifiable Credential containing the verified claims (scopes) requested by the Relying Party. The verification method (e.g. UK Open Banking, document scan) is an implementation detail not exposed to the RP — only the verified scopes and assurance level are returned. -The verification flow mirrors the OTP challenge pattern: +The verification flow mirrors the OTP challenge pattern used by the +merchant payment processor: 1. First call (current_task is None): issue an identity challenge. 2. Second call (same task_id, challenge_response present): validate the response and return a mock JWT VC containing verified_scopes, @@ -26,9 +38,16 @@ from common import message_utils +# AP2 EXTENSION: non-standard data key for the identity credential returned +# by OneID. AP2 v0.1 defines no canonical key for user-side identity +# assertions travelling between agents. +# See: https://ap2-protocol.org/specification/#roadmap IDENTITY_CREDENTIAL_DATA_KEY = "oneid.IdentityCredential" # A structurally realistic but cryptographically invalid mock JWT for demo use. +# TODO(production): remove _MOCK_JWT entirely. The real JWT is issued by the +# OneID token endpoint after the user completes bank authentication and is +# returned as a signed ES256 vc+jwt. See _complete_identity_verification below. _MOCK_JWT = ( "eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK2p3dCJ9" ".eyJzdWIiOiJ1c2VyIiwiaXNzIjoiaHR0cHM6Ly9vbmVpZC51ayIsImlhdCI6MTc0NTA3" @@ -68,7 +87,9 @@ async def handle_verify_identity( message_utils.find_data_part("challenge_response", data_parts) or "" ) if challenge_response: - await _complete_identity_verification(updater, valid_until=valid_until) + await _complete_identity_verification( + updater, auth_code=challenge_response, valid_until=valid_until + ) else: # Re-issue the challenge if no response was provided. await _raise_identity_challenge(updater) @@ -83,6 +104,33 @@ async def _raise_identity_challenge(updater: TaskUpdater) -> None: confirmation, matching the same pattern the demo uses for the OTP challenge. + TODO(production): Replace this stub with a real OneID OIDC journey: + + 1. Build the authorization URL for OneID's controller (from OID discovery + `authorization_endpoint`, typically `…/v2/authorize`) with `client_id`, + `redirect_uri` (registered for the RP), `response_type=code`, + `scope` (e.g. `openid` + claim scopes such as `age_over_18`), `state`, + and PKCE (`code_challenge`, `code_challenge_method=S256`) per integration. + + 2. Send the user to that URL; they complete verification inside OneID (e.g. + bank / IDV). Do not simulate that step in chat. + + 3. After success, the browser returns to `redirect_uri` with `code` and + `state`. Your backend (not the chat) exchanges the code at `POST …/token` + with the `code_verifier` and client authentication. + + 4. Pass through challenge_data whatever the UI needs to open the journey + (full authorize URL and `state` for correlation). Remove the + “(Demo: type 'confirm'…)” prompt when this is live. + + The A2A `requires_input` call remains the same — the calling agent receives + the redirect URL in the DataPart and surfaces it to the user's browser; only + how that URL is produced (real authorize URL vs demo prompt) changes. + + TODO(demo): We do not assume OneID exposes OpenID4VCI, `jwt_vc_json`, or a + credential endpoint. Any VC-shaped artifact for downstream agents is created + or wrapped inside this OneID agent demo, not fetched from OneID. + Args: updater: The task updater. """ @@ -106,6 +154,7 @@ async def _raise_identity_challenge(updater: TaskUpdater) -> None: async def _complete_identity_verification( updater: TaskUpdater, + auth_code: str = "", valid_until: str | None = None, ) -> None: """Returns the mock JWT Verifiable Credential after successful authentication. @@ -114,6 +163,29 @@ async def _complete_identity_verification( For this demo the JWT is structurally realistic but not cryptographically valid — the proof.jwt is a placeholder. + TODO(production): Replace the mock credential with real claims from OneID's + OIDC flow only: + + 1. `auth_code` contains the authorization code that arrived at your + `redirect_uri` callback (delivered here as the A2A `challenge_response`). + Exchange it at `POST …/token` with `grant_type=authorization_code`, + `code=auth_code`, `redirect_uri`, `code_verifier`, and client + authentication. + + 2. Read verified attributes from `id_token` (JWT) and/or `GET …/userinfo` + with `Authorization: Bearer `. Map those claims into the + structure AP2 expects. + + 3. Validate tokens as required (issuer, audience, signature, expiry). + + 4. Drop or adjust demo-only fields that are not in real responses. + + TODO(demo): The W3C `VerifiableCredential`-style object, `proof.jwt`, and any + jwt_vc_json / OID4VCI semantics are mocked here for the sample. OneID is not + assumed to issue that format; remove `_MOCK_JWT` only when you either stop + pretending to emit a VC or replace it with a clearly labeled adapter built on + OIDC claims from step 1-2. + Args: updater: The task updater. valid_until: ISO 8601 expiry copied from the IntentMandate's intent_expiry. diff --git a/samples/python/src/roles/shopping_agent/remote_agents.py b/samples/python/src/roles/shopping_agent/remote_agents.py index d5b8a4bd..9bd3bba0 100644 --- a/samples/python/src/roles/shopping_agent/remote_agents.py +++ b/samples/python/src/roles/shopping_agent/remote_agents.py @@ -43,6 +43,10 @@ ) +# AP2 EXTENSION (OneID demo): The AP2 spec does not define an Identity +# Credential Provider role. This client connects to our demo OneID agent, +# which is not part of the standard AP2 agent registry. +# See: https://ap2-protocol.org/specification/#roadmap oneid_identity_provider_client = PaymentRemoteA2aClient( name="oneid_identity_provider", base_url="http://localhost:8004/a2a/oneid_identity_provider", From fb2d96273ba5807c660f3c6fd6bc878fb097efab Mon Sep 17 00:00:00 2001 From: James Weaver Date: Thu, 16 Apr 2026 13:16:40 +0100 Subject: [PATCH 10/14] more comments --- .../src/roles/oneid_identity_provider/tools.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/samples/python/src/roles/oneid_identity_provider/tools.py b/samples/python/src/roles/oneid_identity_provider/tools.py index a05c3ee8..03c9d3d0 100644 --- a/samples/python/src/roles/oneid_identity_provider/tools.py +++ b/samples/python/src/roles/oneid_identity_provider/tools.py @@ -159,9 +159,10 @@ async def _complete_identity_verification( ) -> None: """Returns the mock JWT Verifiable Credential after successful authentication. - In production OneID would return a signed JWT VC from https://oneid.uk. - For this demo the JWT is structurally realistic but not cryptographically - valid — the proof.jwt is a placeholder. + DEMO NOTE: The credential below is W3C VC-shaped but not compliant (missing + @context, snake_case field names, fake JWT) and not cryptographically secure + (the outer JSON is unsigned — claim values could be altered undetected). + Fine for a demo; not for production. TODO(production): Replace the mock credential with real claims from OneID's OIDC flow only: @@ -180,6 +181,13 @@ async def _complete_identity_verification( 4. Drop or adjust demo-only fields that are not in real responses. + To make the result cryptographically verifiable by a downstream merchant + or payment processor, wrap the verified claims in a proper `vc+jwt` — a + JWT signed with OneID's ES256 private key, with `typ: vc+jwt` in the + header and the credential subject in the `vc` claim. Verifiers then fetch + OneID's public key from `https://oneid.uk/.well-known/jwks.json` and + verify the signature without trusting the shopping agent's word. + TODO(demo): The W3C `VerifiableCredential`-style object, `proof.jwt`, and any jwt_vc_json / OID4VCI semantics are mocked here for the sample. OneID is not assumed to issue that format; remove `_MOCK_JWT` only when you either stop From bec6eddaf9d0222e463fa93d383ef8566c971b28 Mon Sep 17 00:00:00 2001 From: James Weaver Date: Thu, 16 Apr 2026 13:49:22 +0100 Subject: [PATCH 11/14] remove merchant requirement from HOOTL flow, to better show gap 3DS2 cant cover --- .../python/src/roles/shopping_agent/agent.py | 29 ++++++++++++++----- .../shopping_agent/subagents/shopper/agent.py | 23 ++++++++------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/samples/python/src/roles/shopping_agent/agent.py b/samples/python/src/roles/shopping_agent/agent.py index 8e6b9807..fe349207 100644 --- a/samples/python/src/roles/shopping_agent/agent.py +++ b/samples/python/src/roles/shopping_agent/agent.py @@ -191,7 +191,7 @@ Section 1 — 'Delegation Summary': - Intent: natural language description from the IntentMandate - - Merchant: from the IntentMandate + - Merchants: Any — agent will find the best option at execution time - Shipping: the collected address - Payment: the collected payment method alias - Mode: Autonomous (no cart confirmation required) @@ -210,8 +210,8 @@ 9. Call `cosign_intent_mandate`. Show the result. 10. Display a final summary with two blocks: Block 1 — 'Intent Mandate': - - Item description - - Merchant + - Intent: natural language description + - Merchants: Any — agent will find the best option - Shipping address - Payment method - Mode: Autonomous (user_cart_confirmation_required=False) @@ -221,11 +221,24 @@ - Framework (UK_DIATF) - Assurance level - Note: IntentMandate co-signed with verified OneID identity - Then close with: "Your Intent Mandate is signed and your - identity verified. In a production system, your agent would now - contact [merchant], find the best match for '[intent - description]', and complete the purchase — all without requiring - you to be present." + Then close with the following, substituting the placeholders + from the actual IntentMandate: + + "Your Intent Mandate is signed and your identity verified. In a + production system your agent is now authorised to search any + merchant for '[intent description]', reject anything above + [price ceiling], apply your constraints, and pay using + [payment method] — the moment [timing condition] is met, without + you needing to be present. + + Note: per the AP2 spec (Section 5.2), any actor in the + transaction — the merchant or payment provider — may still raise + a challenge that brings you back into the loop, for example for + fraud prevention or regulatory reasons. Your verified OneID + identity is shared with those actors precisely to reduce the + likelihood of that: a bank-verified identity gives merchants and + payment providers the confidence to approve the transaction + frictionlessly, without interrupting you." Scenario 4: The users ask you do to anything else. diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py index 88a8b973..4af9ea75 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/agent.py @@ -46,28 +46,29 @@ When instructed by the root agent to collect intent for HOOTL (autonomous) mode, follow these instructions instead: H1. Explain to the user that in HOOTL (autonomous) mode the agent will - act on their behalf without requiring them to confirm the cart. A - specific merchant must be named upfront so the agent knows where to - shop. - H2. Ask which merchant they want to use for this autonomous purchase. - H3. Gather the delegation details one question at a time: + act on their behalf without requiring them to confirm the cart. No + merchant needs to be specified — the agent will find the best option + at execution time. + H2. Gather the delegation details one question at a time: - What: item description and quantity (e.g., "2 concert tickets") - Constraints: quality or location requirements (e.g., "close to main stage") - Price ceiling: the maximum total the user is willing to spend - Timing: when the agent should act (e.g., "as soon as available") - Refundability: whether items must be refundable (optional) - H4. Create the IntentMandate using `create_intent_mandate` with: + H3. Create the IntentMandate using `create_intent_mandate` with: - user_cart_confirmation_required=False - - merchants=[the specified merchant] + - merchants=[] (no merchant constraint — agent searches freely) - natural_language_description that captures the full delegation intent including quantity, constraints, price ceiling, and timing (e.g., "2 concert tickets — close to main stage — max $1000 — purchase as soon as available") - H5. Present the IntentMandate to the user, formatted as in step 4 of the + H4. Present the IntentMandate to the user, formatted as in step 4 of the standard flow below, but highlight that User Confirmation Required is - 'No — Autonomous mode'. End with: "By confirming, you are authorising - your agent to complete this purchase autonomously. Shall I proceed?" - H6. Once confirmed, immediately transfer back to the root_agent. Your + 'No — Autonomous mode' and Merchants is 'Any — agent will find the + best option at execution time'. End with: "By confirming, you are + authorising your agent to complete this purchase autonomously. Shall + I proceed?" + H5. Once confirmed, immediately transfer back to the root_agent. Your transfer message should be: "HOOTL IntentMandate confirmed." When asked to complete a standard (HITL) shopping task, follow these From ddc9f63d30a27c080c21cfd4439ce36cf80f858c Mon Sep 17 00:00:00 2001 From: James Weaver Date: Mon, 20 Apr 2026 11:34:19 +0100 Subject: [PATCH 12/14] extend returned claim example for identity verification path --- .../roles/oneid_identity_provider/tools.py | 62 +++++++++++++++---- .../python/src/roles/shopping_agent/tools.py | 5 ++ 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/samples/python/src/roles/oneid_identity_provider/tools.py b/samples/python/src/roles/oneid_identity_provider/tools.py index 03c9d3d0..0e629fc1 100644 --- a/samples/python/src/roles/oneid_identity_provider/tools.py +++ b/samples/python/src/roles/oneid_identity_provider/tools.py @@ -44,17 +44,29 @@ # See: https://ap2-protocol.org/specification/#roadmap IDENTITY_CREDENTIAL_DATA_KEY = "oneid.IdentityCredential" -# A structurally realistic but cryptographically invalid mock JWT for demo use. -# TODO(production): remove _MOCK_JWT entirely. The real JWT is issued by the -# OneID token endpoint after the user completes bank authentication and is -# returned as a signed ES256 vc+jwt. See _complete_identity_verification below. -_MOCK_JWT = ( +# Structurally realistic but cryptographically invalid mock JWTs for demo use. +# TODO(production): remove both. The real JWT is issued by the OneID token +# endpoint after the user completes bank authentication and is returned as a +# signed ES256 vc+jwt. See _complete_identity_verification below. + +# Age verification only (HITL): credentialSubject contains age_over_18 only. +_MOCK_JWT_AGE = ( "eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK2p3dCJ9" ".eyJzdWIiOiJ1c2VyIiwiaXNzIjoiaHR0cHM6Ly9vbmVpZC51ayIsImlhdCI6MTc0NTA3" "MDAwMCwidmMiOnsiY3JlZGVudGlhbFN1YmplY3QiOnsiYWdlX292ZXJfMTgiOnRydWV9fX0" ".DEMO_SIGNATURE_NOT_CRYPTOGRAPHICALLY_VALID" ) +# Full identity (HOOTL): credentialSubject contains openid, profile, date_of_birth, age_over_18. +_MOCK_JWT_IDENTITY = ( + "eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK2p3dCJ9" + ".eyJzdWIiOiJ1c2VyIiwiaXNzIjoiaHR0cHM6Ly9vbmVpZC51ayIsImlhdCI6MTc0NTA3" + "MDAwMCwidmMiOnsiY3JlZGVudGlhbFN1YmplY3QiOnsib3BlbmlkIjp0cnVlLCJwcm9maWxlIjp7" + "ImdpdmVuX25hbWUiOiJCdWdzIiwiZmFtaWx5X25hbWUiOiJCdW5ueSIsIm5hbWUiOiJCdWdzIEJ1" + "bm55In0sImRhdGVfb2ZfYmlydGgiOiIxOTQwLTA3LTI3IiwiYWdlX292ZXJfMTgiOnRydWV9fX0" + ".DEMO_SIGNATURE_NOT_CRYPTOGRAPHICALLY_VALID" +) + async def handle_verify_identity( data_parts: list[dict[str, Any]], @@ -87,8 +99,12 @@ async def handle_verify_identity( message_utils.find_data_part("challenge_response", data_parts) or "" ) if challenge_response: + include_profile = bool(verification_requirements.get("profile")) await _complete_identity_verification( - updater, auth_code=challenge_response, valid_until=valid_until + updater, + auth_code=challenge_response, + valid_until=valid_until, + include_profile=include_profile, ) else: # Re-issue the challenge if no response was provided. @@ -156,6 +172,7 @@ async def _complete_identity_verification( updater: TaskUpdater, auth_code: str = "", valid_until: str | None = None, + include_profile: bool = False, ) -> None: """Returns the mock JWT Verifiable Credential after successful authentication. @@ -204,19 +221,38 @@ async def _complete_identity_verification( # were requested and confirmed. The verification method (e.g. which bank # or document provider was used) is an internal OneID detail and is NOT # returned to the Relying Party. + if include_profile: + credential_subject = { + "verified_scopes": ["openid", "profile", "date_of_birth", "age_over_18"], + "openid": True, + "profile": { + "given_name": "Bugs", + "family_name": "Bunny", + "name": "Bugs Bunny", + }, + "date_of_birth": "1940-07-27", + "age_over_18": True, + "assurance_level": "high", + "framework": "UK_DIATF", + } + mock_jwt = _MOCK_JWT_IDENTITY + else: + credential_subject = { + "verified_scopes": ["age_over_18", "openid"], + "age_over_18": True, + "assurance_level": "high", + "framework": "UK_DIATF", + } + mock_jwt = _MOCK_JWT_AGE + credential = { "type": "VerifiableCredential", "issuer": "https://oneid.uk", "issuance_date": datetime.now(timezone.utc).isoformat(), - "credential_subject": { - "verified_scopes": ["age_over_18", "openid"], - "age_over_18": True, - "assurance_level": "high", - "framework": "UK_DIATF", - }, + "credential_subject": credential_subject, "proof": { "type": "jwt", - "jwt": _MOCK_JWT, + "jwt": mock_jwt, }, } if valid_until: diff --git a/samples/python/src/roles/shopping_agent/tools.py b/samples/python/src/roles/shopping_agent/tools.py index 74804806..aa6b9d68 100644 --- a/samples/python/src/roles/shopping_agent/tools.py +++ b/samples/python/src/roles/shopping_agent/tools.py @@ -138,6 +138,8 @@ async def request_identity_verification( .add_text("verify_identity") .add_data("verification_requirements", { "age_over_18": True, + "profile": cart_mandate is None, + "date_of_birth": cart_mandate is None, "valid_until": valid_until, }) .add_data("debug_mode", debug_mode) @@ -145,6 +147,7 @@ async def request_identity_verification( ) task = await oneid_identity_provider_client.send_a2a_message(message) tool_context.state["oneid_task_id"] = task.id + tool_context.state["oneid_include_profile"] = cart_mandate is None if task.status and task.status.message: for part in (task.status.message.parts or []): @@ -208,6 +211,8 @@ async def complete_identity_verification( .add_text("verify_identity") .add_data("verification_requirements", { "age_over_18": True, + "profile": tool_context.state.get("oneid_include_profile", False), + "date_of_birth": tool_context.state.get("oneid_include_profile", False), "valid_until": valid_until, }) .add_data("challenge_response", confirmation) From 436ff1c1671b96126d9c7268c7ec14bea7776600 Mon Sep 17 00:00:00 2001 From: James Weaver Date: Mon, 20 Apr 2026 11:42:38 +0100 Subject: [PATCH 13/14] self review --- .../python/src/roles/oneid_identity_provider/tools.py | 9 +++++---- samples/python/src/roles/shopping_agent/tools.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/samples/python/src/roles/oneid_identity_provider/tools.py b/samples/python/src/roles/oneid_identity_provider/tools.py index 0e629fc1..45b8094c 100644 --- a/samples/python/src/roles/oneid_identity_provider/tools.py +++ b/samples/python/src/roles/oneid_identity_provider/tools.py @@ -6,9 +6,10 @@ demo extension illustrating how such a role *could* be integrated within the AP2 agent-to-agent trust model. -The verified credential returned here (oneid.IdentityCredential) is a -non-standard data key. In a future AP2 revision, a canonical mechanism for -conveying identity assertions between agents would make this key unnecessary. +The verified credential returned here uses the key ap2.identityCredential — +a proposed generic key for the Identity Credential Provider role. In a future +AP2 revision, a canonical mechanism for conveying identity assertions between +agents would standardise this key. See the AP2 roadmap: https://ap2-protocol.org/specification/#roadmap OneID verifies user identity and returns a JWT Verifiable Credential @@ -42,7 +43,7 @@ # by OneID. AP2 v0.1 defines no canonical key for user-side identity # assertions travelling between agents. # See: https://ap2-protocol.org/specification/#roadmap -IDENTITY_CREDENTIAL_DATA_KEY = "oneid.IdentityCredential" +IDENTITY_CREDENTIAL_DATA_KEY = "ap2.identityCredential" # Structurally realistic but cryptographically invalid mock JWTs for demo use. # TODO(production): remove both. The real JWT is issued by the OneID token diff --git a/samples/python/src/roles/shopping_agent/tools.py b/samples/python/src/roles/shopping_agent/tools.py index aa6b9d68..f49d7d05 100644 --- a/samples/python/src/roles/shopping_agent/tools.py +++ b/samples/python/src/roles/shopping_agent/tools.py @@ -225,8 +225,8 @@ async def complete_identity_verification( for artifact in (task.artifacts or []): for part in (artifact.parts or []): data = getattr(part.root, "data", None) - if data and "oneid.IdentityCredential" in data: - identity_credential = data["oneid.IdentityCredential"] + if data and "ap2.identityCredential" in data: + identity_credential = data["ap2.identityCredential"] break if identity_credential: From 7b9117d9d2d64c424c0d39ada33a0e25fcf42008 Mon Sep 17 00:00:00 2001 From: James Weaver Date: Mon, 20 Apr 2026 12:01:52 +0100 Subject: [PATCH 14/14] self review 2 - age verification from datapart to cartcontents --- .../sub_agents/catalog_agent.py | 34 +++++++++++-------- .../shopping_agent/subagents/shopper/tools.py | 20 +---------- src/ap2/types/mandate.py | 18 ++++++++++ 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py b/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py index b9a66159..1ecdb7f2 100644 --- a/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py +++ b/samples/python/src/roles/merchant_agent/sub_agents/catalog_agent.py @@ -50,10 +50,12 @@ # This is a demo heuristic — in a production system the merchant's catalogue # would carry explicit age-restriction metadata rather than relying on keyword # matching. -# AP2 EXTENSION: The AP2 spec does not define a mechanism for merchants to -# signal age restrictions within a CartMandate or cart response. We add the -# `age_verification_required` DataPart as a non-spec extension so the shopping -# agent can trigger identity verification with the appropriate UX. +# AP2 EXTENSION: The AP2 spec defines no mechanism for merchants to signal +# verification requirements within a cart. We set verification_requirements +# on CartContents — the object the merchant cryptographically signs — so the +# requirement is bound to the offer and travels with it. This follows the +# precedent of user_cart_confirmation_required and is proposed as a natural +# typed extension to the CartContents schema. # See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate _AGE_RESTRICTED_KEYWORDS = frozenset({ "wine", "beer", "ale", "lager", "stout", "cider", @@ -100,6 +102,17 @@ async def find_items_workflow( try: items: list[PaymentItem] = llm_response.parsed current_time = datetime.now(timezone.utc) + + # Dual-layer age restriction detection: the LLM was instructed to append + # '[Age 18+ required]' to restricted item labels; we also verify here with + # a deterministic keyword check so the flag is reliable regardless of LLM + # compliance. Both checks run — the label provides user-visible context. + age_verification_required = any( + any(kw in item.label.lower() for kw in _AGE_RESTRICTED_KEYWORDS) + for item in items + ) + verification_requirements = ["age_over_18"] if age_verification_required else None + item_count = 0 for item in items: item_count += 1 @@ -109,21 +122,12 @@ async def find_items_workflow( current_time, updater, os.environ.get("PAYMENT_METHOD", "CARD"), + verification_requirements, ) - # Dual-layer age restriction detection: the LLM was instructed to append - # '[Age 18+ required]' to restricted item labels; we also verify here with - # a deterministic keyword check so the flag is reliable regardless of LLM - # compliance. Both checks run — the label provides user-visible context. - age_verification_required = any( - any(kw in item.label.lower() for kw in _AGE_RESTRICTED_KEYWORDS) - for item in items - ) - risk_data = _collect_risk_data(updater) updater.add_artifact([ Part(root=DataPart(data={"risk_data": risk_data})), - Part(root=DataPart(data={"age_verification_required": age_verification_required})), ]) await updater.complete() except ValidationError as e: @@ -140,6 +144,7 @@ async def _create_and_add_cart_mandate_artifact( current_time: datetime, updater: TaskUpdater, payment_method: str, + verification_requirements: list[str] | None = None, ) -> None: """Creates a CartMandate and adds it as an artifact.""" if payment_method == "x402": @@ -186,6 +191,7 @@ async def _create_and_add_cart_mandate_artifact( payment_request=payment_request, cart_expiry=(current_time + timedelta(minutes=30)).isoformat(), merchant_name="Generic Merchant", + verification_requirements=verification_requirements, ) cart_mandate = CartMandate(contents=cart_contents) diff --git a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py index 7d08314b..c9415065 100644 --- a/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py +++ b/samples/python/src/roles/shopping_agent/subagents/shopper/tools.py @@ -107,8 +107,6 @@ async def find_products( tool_context.state["shopping_context_id"] = task.context_id cart_mandates = _parse_cart_mandates(task.artifacts) tool_context.state["cart_mandates"] = cart_mandates - age_verification_required = _parse_age_verification_required(task.artifacts) - tool_context.state["age_verification_required"] = age_verification_required return cart_mandates @@ -127,7 +125,7 @@ def update_chosen_cart_mandate(cart_id: str, tool_context: ToolContext) -> str: if cart.contents.id == cart_id: tool_context.state["chosen_cart_id"] = cart_id age_note = "" - if tool_context.state.get("age_verification_required"): + if cart.contents.verification_requirements and "age_over_18" in cart.contents.verification_requirements: age_note = " Age verification (18+) is required for items in this cart." return f"CartMandate with ID {cart_id} selected.{age_note}" return f"CartMandate with ID {cart_id} not found." @@ -138,22 +136,6 @@ def _parse_cart_mandates(artifacts: list[Artifact]) -> list[CartMandate]: return find_canonical_objects(artifacts, CART_MANDATE_DATA_KEY, CartMandate) -def _parse_age_verification_required(artifacts: list[Artifact]) -> bool: - """Returns True if the merchant flagged any cart item as age-restricted. - - AP2 EXTENSION: `age_verification_required` is not a standard AP2 field. - It is emitted by the catalog agent as a demo extension to signal that the - shopping agent should trigger OneID age verification before co-signing the - CartMandate. - See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate - """ - for artifact in (artifacts or []): - for part in (artifact.parts or []): - data = getattr(part.root, "data", None) - if data and "age_verification_required" in data: - return bool(data["age_verification_required"]) - return False - def _collect_risk_data(tool_context: ToolContext) -> dict: """Creates a risk_data in the tool_context.""" diff --git a/src/ap2/types/mandate.py b/src/ap2/types/mandate.py index 978c7cde..af6355a7 100644 --- a/src/ap2/types/mandate.py +++ b/src/ap2/types/mandate.py @@ -141,6 +141,24 @@ class CartContents(BaseModel): ) merchant_name: str = Field(..., description="The name of the merchant.") + # AP2 EXTENSION: The spec defines no mechanism for merchants to declare + # verification requirements as part of a signed cart offer. This field follows + # the precedent of user_cart_confirmation_required — a merchant-declared value + # that governs transaction behaviour — and proposes verification_requirements + # as a natural, typed extension to CartContents. Because CartContents is the + # object the merchant cryptographically signs, it is the appropriate home for + # such a declaration: the requirement travels with the signed offer and cannot + # be altered in transit. Example value: ["age_over_18"]. + # See: https://ap2-protocol.org/topics/core-concepts/#cart-mandate + verification_requirements: Optional[list[str]] = Field( + None, + description=( + "AP2 EXTENSION: Verification requirements the purchasing party must" + " satisfy before this cart can be confirmed. Proposed as a typed" + " extension to CartContents in the AP2 spec. Example: ['age_over_18']." + ), + ) + class CartMandate(BaseModel): """A cart whose contents have been digitally signed by the merchant.