-
Notifications
You must be signed in to change notification settings - Fork 138
feat(sdk): add extension for user approval #1775
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
5962121
feat(sdk): add extension for user approval
pilartomas 1af57f6
fixup!
pilartomas 0706811
fixup!
pilartomas f48bb93
fixup!
pilartomas f4ab781
fixup!
pilartomas 1abcde0
fixup!
pilartomas 0599748
fixup! review updates
pilartomas File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
4 changes: 4 additions & 0 deletions
4
apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/interactions/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| # Copyright 2025 © BeeAI a Series of LF Projects, LLC | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| from .approval import * | ||
125 changes: 125 additions & 0 deletions
125
apps/agentstack-sdk-py/src/agentstack_sdk/a2a/extensions/interactions/approval.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| # Copyright 2025 © BeeAI a Series of LF Projects, LLC | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import uuid | ||
| from types import NoneType | ||
| from typing import TYPE_CHECKING, Annotated, Any, Literal | ||
|
|
||
| import a2a.types | ||
| from mcp import Implementation, Tool | ||
| from pydantic import BaseModel, Discriminator, Field, TypeAdapter | ||
|
|
||
| from agentstack_sdk.a2a.extensions.base import BaseExtensionClient, BaseExtensionServer, BaseExtensionSpec | ||
| from agentstack_sdk.a2a.types import AgentMessage, InputRequired | ||
|
|
||
| if TYPE_CHECKING: | ||
| from agentstack_sdk.server.context import RunContext | ||
|
|
||
|
|
||
| class ApprovalRejectionError(RuntimeError): | ||
| pass | ||
|
|
||
|
|
||
| class GenericApprovalRequest(BaseModel): | ||
| action: Literal["generic"] = "generic" | ||
|
|
||
| title: str | None = Field(None, description="A human-readable title for the action being approved.") | ||
| description: str | None = Field(None, description="A human-readable description of the action being approved.") | ||
|
|
||
|
|
||
| class ToolCallServer(BaseModel): | ||
| name: str = Field(description="The programmatic name of the server.") | ||
| title: str | None = Field(description="A human-readable title for the server.") | ||
| version: str = Field(description="The version of the server.") | ||
|
|
||
|
|
||
| class ToolCallApprovalRequest(BaseModel): | ||
| action: Literal["tool-call"] = "tool-call" | ||
|
|
||
| title: str | None = Field(None, description="A human-readable title for the tool call being approved.") | ||
| description: str | None = Field(None, description="A human-readable description of the tool call being approved.") | ||
| name: str = Field(description="The programmatic name of the tool.") | ||
| input: dict[str, Any] | None = Field(description="The input for the tool.") | ||
| server: ToolCallServer | None = Field(None, description="The server executing the tool.") | ||
|
|
||
| @staticmethod | ||
| def from_mcp_tool( | ||
| tool: Tool, input: dict[str, Any] | None, server: Implementation | None = None | ||
| ) -> ToolCallApprovalRequest: | ||
| return ToolCallApprovalRequest( | ||
| name=tool.name, | ||
| title=tool.annotations.title if tool.annotations else None, | ||
| description=tool.description, | ||
| input=input, | ||
| server=ToolCallServer(name=server.name, title=server.title, version=server.version) if server else None, | ||
| ) | ||
|
|
||
|
|
||
| ApprovalRequest = Annotated[GenericApprovalRequest | ToolCallApprovalRequest, Discriminator("action")] | ||
|
|
||
|
|
||
| class ApprovalResponse(BaseModel): | ||
| decision: Literal["approve", "reject"] | ||
|
|
||
| @property | ||
| def approved(self) -> bool: | ||
| return self.decision == "approve" | ||
|
|
||
| def raise_on_rejection(self) -> None: | ||
| if self.decision == "reject": | ||
| raise ApprovalRejectionError("Approval request has been rejected") | ||
|
|
||
|
|
||
| class ApprovalExtensionParams(BaseModel): | ||
| pass | ||
|
|
||
|
|
||
| class ApprovalExtensionSpec(BaseExtensionSpec[ApprovalExtensionParams]): | ||
| URI: str = "https://a2a-extensions.agentstack.beeai.dev/interactions/approval/v1" | ||
|
|
||
|
|
||
| class ApprovalExtensionMetadata(BaseModel): | ||
| pass | ||
|
|
||
|
|
||
| class ApprovalExtensionServer(BaseExtensionServer[ApprovalExtensionSpec, ApprovalExtensionMetadata]): | ||
| def create_request_message(self, *, request: ApprovalRequest): | ||
| return AgentMessage(text="Approval requested", metadata={self.spec.URI: request.model_dump(mode="json")}) | ||
|
|
||
| def parse_response(self, *, message: a2a.types.Message): | ||
| if not message.metadata or not (data := message.metadata.get(self.spec.URI)): | ||
| raise ValueError("Approval response data is missing") | ||
| return ApprovalResponse.model_validate(data) | ||
|
|
||
| async def request_approval( | ||
| self, | ||
| request: ApprovalRequest, | ||
| *, | ||
| context: RunContext, | ||
| ) -> ApprovalResponse: | ||
| message = self.create_request_message(request=request) | ||
| message = await context.yield_async(InputRequired(message=message)) | ||
| if not message: | ||
| raise RuntimeError("Yield did not return a message") | ||
| return self.parse_response(message=message) | ||
|
|
||
|
|
||
| class ApprovalExtensionClient(BaseExtensionClient[ApprovalExtensionSpec, NoneType]): | ||
| def create_response_message(self, *, response: ApprovalResponse, task_id: str | None): | ||
| return a2a.types.Message( | ||
| message_id=str(uuid.uuid4()), | ||
| role=a2a.types.Role.user, | ||
| parts=[], | ||
| task_id=task_id, | ||
| metadata={self.spec.URI: response.model_dump(mode="json")}, | ||
| ) | ||
|
|
||
| def parse_request(self, *, message: a2a.types.Message): | ||
| if not message.metadata or not (data := message.metadata.get(self.spec.URI)): | ||
| raise ValueError("Approval request data is missing") | ||
| return TypeAdapter(ApprovalRequest).validate_python(data) | ||
|
|
||
| def metadata(self) -> dict[str, Any]: | ||
| return {self.spec.URI: ApprovalExtensionMetadata().model_dump(mode="json")} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
50 changes: 50 additions & 0 deletions
50
apps/agentstack-sdk-ts/src/client/a2a/extensions/interactions/approval.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| /** | ||
| * Copyright 2025 © BeeAI a Series of LF Projects, LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import z from 'zod'; | ||
|
|
||
| import type { A2AUiExtension } from '../types'; | ||
|
|
||
| const URI = 'https://a2a-extensions.agentstack.beeai.dev/interactions/approval/v1'; | ||
|
|
||
| export const genericApprovalRequestSchema = z.object({ | ||
| action: z.literal('generic'), | ||
| title: z.string().nullish().describe('A human-readable title for the action being approved.'), | ||
| description: z.string().nullish().describe('A human-readable description of the action being approved.'), | ||
| }); | ||
| export type GenericApprovalRequest = z.infer<typeof genericApprovalRequestSchema>; | ||
|
|
||
| export const toolCallApprovalRequestSchema = z.object({ | ||
| action: z.literal('tool-call'), | ||
| title: z.string().nullish().describe('A human-readable title for the tool call being approved.'), | ||
| description: z.string().nullish().describe('A human-readable description of the tool call being approved.'), | ||
| name: z.string().describe('The programmatic name of the tool.'), | ||
| input: z.object().nullish().describe('The input for the tool.'), | ||
| server: z | ||
| .object({ | ||
| name: z.string().describe('The programmatic name of the server.'), | ||
| title: z.string().nullish().describe('A human-readable title for the server.'), | ||
| version: z.string().describe('The version of the server.'), | ||
| }) | ||
| .nullish() | ||
| .describe('The server executing the tool.'), | ||
| }); | ||
| export type ToolCallApprovalRequest = z.infer<typeof toolCallApprovalRequestSchema>; | ||
|
|
||
| export const approvalRequestSchema = z.discriminatedUnion('action', [ | ||
| genericApprovalRequestSchema, | ||
| toolCallApprovalRequestSchema, | ||
| ]); | ||
| export type ApprovalRequest = z.infer<typeof approvalRequestSchema>; | ||
|
|
||
| export const approvalResultSchema = z.object({ | ||
| decision: z.enum(['approve', 'reject']), | ||
| }); | ||
| export type ApprovalResult = z.infer<typeof approvalResultSchema>; | ||
|
|
||
| export const approvalExtension: A2AUiExtension<typeof URI, ApprovalRequest> = { | ||
| getMessageMetadataSchema: () => z.object({ [URI]: approvalRequestSchema }).partial(), | ||
| getUri: () => URI, | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.