Skip to content

Commit 7c46e70

Browse files
kthota-gzboylesLaurentMnr95google-labs-jules[bot]holtskinner
authored
feat: Support for database backend Task Store (#259)
``` Release-As: 0.2.11 ``` Add support for database backed TaskStore ### Installation ```bash uv add a2a-sdk[postgresql] #postgres driver or uv add a2a-sdk[mysql] #mysql driver or uv add a2a-sdk[sqlite] #sqlite driver or uv add a2a-sdk[sql]. #install all three sql drivers ``` ### Usage ```py from a2a.server.tasks import DatabaseTaskStore from sqlalchemy.ext.asyncio import ( create_async_engine, ) ... ... # Postgres - "postgresql+asyncpg://postgres:postgres@localhost:5432/a2a_test" # Sqlite - "sqlite+aiosqlite:///file::memory:?cache=shared" # Mysql - "mysql+aiomysql://admin:saysoyeah@localhost:3306/testDB" engine = create_async_engine( "postgresql+asyncpg://postgres:postgres@localhost:5432/a2a_test", echo=False ) request_handler = DefaultRequestHandler( agent_executor=CurrencyAgentExecutor(), task_store=DatabaseTaskStore(engine=engine), push_notifier=InMemoryPushNotifier(httpx_client), ) ``` --------- Co-authored-by: Zac <zac.boyles@live.com> Co-authored-by: MEUNIER Laurent <laurent@breezyai.co> Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Zac <2215540+zboyles@users.noreply.github.com> Co-authored-by: Holt Skinner <holtskinner@google.com> Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com>
1 parent abd4ca8 commit 7c46e70

File tree

12 files changed

+1673
-618
lines changed

12 files changed

+1673
-618
lines changed

.github/actions/spelling/allow.txt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,22 @@ AServer
99
AServers
1010
AService
1111
AStarlette
12+
AUser
13+
DSNs
1214
EUR
1315
GBP
16+
GVsb
1417
INR
1518
JPY
1619
JSONRPCt
1720
Llm
21+
POSTGRES
1822
RUF
1923
aconnect
2024
adk
2125
agentic
2226
aio
27+
aiomysql
2328
aproject
2429
autouse
2530
backticks
@@ -29,23 +34,34 @@ coc
2934
codegen
3035
coro
3136
datamodel
37+
drivername
3238
dunders
3339
euo
40+
excinfo
41+
fetchrow
42+
fetchval
3443
genai
3544
getkwargs
3645
gle
46+
initdb
3747
inmemory
48+
isready
3849
kwarg
3950
langgraph
4051
lifecycles
4152
linting
4253
lstrips
4354
mockurl
55+
notif
4456
oauthoidc
4557
oidc
4658
opensource
59+
otherurl
60+
postgres
61+
postgresql
4762
protoc
4863
pyi
64+
pypistats
4965
pyversions
5066
respx
5167
resub

.github/actions/spelling/expect.txt

Lines changed: 0 additions & 5 deletions
This file was deleted.

.github/workflows/unit-tests.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,18 @@ jobs:
99
test:
1010
name: Test with Python ${{ matrix.python-version }}
1111
runs-on: ubuntu-latest
12+
1213
if: github.repository == 'a2aproject/a2a-python'
14+
services:
15+
postgres:
16+
image: postgres:15-alpine
17+
env:
18+
POSTGRES_USER: postgres
19+
POSTGRES_PASSWORD: postgres
20+
POSTGRES_DB: a2a_test
21+
ports:
22+
- 5432:5432
23+
1324
strategy:
1425
matrix:
1526
python-version: ['3.10', '3.13']
@@ -20,13 +31,19 @@ jobs:
2031
uses: actions/setup-python@v5
2132
with:
2233
python-version: ${{ matrix.python-version }}
34+
- name: Set postgres for tests
35+
run: |
36+
sudo apt-get update && sudo apt-get install -y postgresql-client
37+
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d a2a_test -f ${{ github.workspace }}/docker/postgres/init.sql
38+
export POSTGRES_TEST_DSN="postgresql+asyncpg://postgres:postgres@localhost:5432/a2a_test"
39+
2340
- name: Install uv
2441
uses: astral-sh/setup-uv@v6
2542
- name: Add uv to PATH
2643
run: |
2744
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
2845
- name: Install dependencies
29-
run: uv sync --dev
46+
run: uv sync --dev --extra sql
3047
- name: Run tests and check coverage
3148
run: uv run pytest --cov=a2a --cov-report=xml --cov-fail-under=90
3249
- name: Show coverage summary in log

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ When you're working within a uv project or a virtual environment managed by uv,
3333
uv add a2a-sdk
3434
```
3535

36+
To install with database support:
37+
38+
```bash
39+
# PostgreSQL support
40+
uv add "a2a-sdk[postgresql]"
41+
42+
# MySQL support
43+
uv add "a2a-sdk[mysql]"
44+
45+
# SQLite support
46+
uv add "a2a-sdk[sqlite]"
47+
48+
# All database drivers
49+
uv add "a2a-sdk[sql]"
50+
```
51+
3652
### Using `pip`
3753

3854
If you prefer to use pip, the standard Python package installer, you can install `a2a-sdk` as follows
@@ -41,6 +57,22 @@ If you prefer to use pip, the standard Python package installer, you can install
4157
pip install a2a-sdk
4258
```
4359

60+
To install with database support:
61+
62+
```bash
63+
# PostgreSQL support
64+
pip install "a2a-sdk[postgresql]"
65+
66+
# MySQL support
67+
pip install "a2a-sdk[mysql]"
68+
69+
# SQLite support
70+
pip install "a2a-sdk[sqlite]"
71+
72+
# All database drivers
73+
pip install "a2a-sdk[sql]"
74+
```
75+
4476
## Examples
4577

4678
### [Helloworld Example](https://github.com/a2aproject/a2a-samples/tree/main/samples/python/agents/helloworld)
@@ -60,7 +92,7 @@ pip install a2a-sdk
6092
uv run test_client.py
6193
```
6294

63-
3. You can validate your agent using the agent inspector. Follow the instructions at the [a2a-inspector](https://github.com/a2aproject/a2a-inspector) repo.
95+
3. You can validate your agent using the agent inspector. Follow the instructions at the [a2a-inspector](https://github.com/a2aproject/a2a-inspector) repo.
6496

6597
You can also find more Python samples [here](https://github.com/a2aproject/a2a-samples/tree/main/samples/python) and JavaScript samples [here](https://github.com/a2aproject/a2a-samples/tree/main/samples/js).
6698

docker/postgres/init.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- Create a dedicated user for the application
2+
CREATE USER a2a WITH PASSWORD 'a2a_password';
3+
4+
-- Create the tasks database
5+
CREATE DATABASE a2a_tasks;
6+
7+
GRANT ALL PRIVILEGES ON DATABASE a2a_test TO a2a;
8+

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ classifiers = [
3636
"License :: OSI Approved :: Apache Software License",
3737
]
3838

39+
[project.optional-dependencies]
40+
postgresql = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.0"]
41+
mysql = ["sqlalchemy[asyncio,aiomysql]>=2.0.0"]
42+
sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"]
43+
sql = ["sqlalchemy[asyncio,postgresql-asyncpg,aiomysql,aiosqlite]>=2.0.0"]
44+
3945
[project.urls]
4046
homepage = "https://a2aproject.github.io/A2A/"
4147
repository = "https://github.com/a2aproject/a2a-python"

src/a2a/server/models.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
2+
3+
4+
if TYPE_CHECKING:
5+
from typing_extensions import override
6+
else:
7+
8+
def override(func): # noqa: ANN001, ANN201
9+
"""Override decorator."""
10+
return func
11+
12+
13+
from pydantic import BaseModel
14+
15+
from a2a.types import Artifact, Message, TaskStatus
16+
17+
18+
try:
19+
from sqlalchemy import JSON, Dialect, String
20+
from sqlalchemy.orm import (
21+
DeclarativeBase,
22+
Mapped,
23+
declared_attr,
24+
mapped_column,
25+
)
26+
from sqlalchemy.types import TypeDecorator
27+
except ImportError as e:
28+
raise ImportError(
29+
'Database models require SQLAlchemy. '
30+
'Install with one of: '
31+
"'pip install a2a-sdk[postgresql]', "
32+
"'pip install a2a-sdk[mysql]', "
33+
"'pip install a2a-sdk[sqlite]', "
34+
"or 'pip install a2a-sdk[sql]'"
35+
) from e
36+
37+
38+
T = TypeVar('T', bound=BaseModel)
39+
40+
41+
class PydanticType(TypeDecorator[T], Generic[T]):
42+
"""SQLAlchemy type that handles Pydantic model serialization."""
43+
44+
impl = JSON
45+
cache_ok = True
46+
47+
def __init__(self, pydantic_type: type[T], **kwargs: dict[str, Any]):
48+
"""Initialize the PydanticType.
49+
50+
Args:
51+
pydantic_type: The Pydantic model type to handle.
52+
**kwargs: Additional arguments for TypeDecorator.
53+
"""
54+
self.pydantic_type = pydantic_type
55+
super().__init__(**kwargs)
56+
57+
def process_bind_param(
58+
self, value: T | None, dialect: Dialect
59+
) -> dict[str, Any] | None:
60+
"""Convert Pydantic model to a JSON-serializable dictionary for the database."""
61+
if value is None:
62+
return None
63+
return (
64+
value.model_dump(mode='json')
65+
if isinstance(value, BaseModel)
66+
else value
67+
)
68+
69+
def process_result_value(
70+
self, value: dict[str, Any] | None, dialect: Dialect
71+
) -> T | None:
72+
"""Convert a JSON-like dictionary from the database back to a Pydantic model."""
73+
if value is None:
74+
return None
75+
return self.pydantic_type.model_validate(value)
76+
77+
78+
class PydanticListType(TypeDecorator, Generic[T]):
79+
"""SQLAlchemy type that handles lists of Pydantic models."""
80+
81+
impl = JSON
82+
cache_ok = True
83+
84+
def __init__(self, pydantic_type: type[T], **kwargs: dict[str, Any]):
85+
"""Initialize the PydanticListType.
86+
87+
Args:
88+
pydantic_type: The Pydantic model type for items in the list.
89+
**kwargs: Additional arguments for TypeDecorator.
90+
"""
91+
self.pydantic_type = pydantic_type
92+
super().__init__(**kwargs)
93+
94+
def process_bind_param(
95+
self, value: list[T] | None, dialect: Dialect
96+
) -> list[dict[str, Any]] | None:
97+
"""Convert a list of Pydantic models to a JSON-serializable list for the DB."""
98+
if value is None:
99+
return None
100+
return [
101+
item.model_dump(mode='json')
102+
if isinstance(item, BaseModel)
103+
else item
104+
for item in value
105+
]
106+
107+
def process_result_value(
108+
self, value: list[dict[str, Any]] | None, dialect: Dialect
109+
) -> list[T] | None:
110+
"""Convert a JSON-like list from the DB back to a list of Pydantic models."""
111+
if value is None:
112+
return None
113+
return [self.pydantic_type.model_validate(item) for item in value]
114+
115+
116+
# Base class for all database models
117+
class Base(DeclarativeBase):
118+
"""Base class for declarative models in A2A SDK."""
119+
120+
121+
# TaskMixin that can be used with any table name
122+
class TaskMixin:
123+
"""Mixin providing standard task columns with proper type handling."""
124+
125+
id: Mapped[str] = mapped_column(String(36), primary_key=True, index=True)
126+
contextId: Mapped[str] = mapped_column(String(36), nullable=False) # noqa: N815
127+
kind: Mapped[str] = mapped_column(
128+
String(16), nullable=False, default='task'
129+
)
130+
131+
# Properly typed Pydantic fields with automatic serialization
132+
status: Mapped[TaskStatus] = mapped_column(PydanticType(TaskStatus))
133+
artifacts: Mapped[list[Artifact] | None] = mapped_column(
134+
PydanticListType(Artifact), nullable=True
135+
)
136+
history: Mapped[list[Message] | None] = mapped_column(
137+
PydanticListType(Message), nullable=True
138+
)
139+
140+
# Using declared_attr to avoid conflict with Pydantic's metadata
141+
@declared_attr
142+
@classmethod
143+
def task_metadata(cls) -> Mapped[dict[str, Any] | None]:
144+
"""Define the 'metadata' column, avoiding name conflicts with Pydantic."""
145+
return mapped_column(JSON, nullable=True, name='metadata')
146+
147+
@override
148+
def __repr__(self) -> str:
149+
"""Return a string representation of the task."""
150+
repr_template = (
151+
'<{CLS}(id="{ID}", contextId="{CTX_ID}", status="{STATUS}")>'
152+
)
153+
return repr_template.format(
154+
CLS=self.__class__.__name__,
155+
ID=self.id,
156+
CTX_ID=self.contextId,
157+
STATUS=self.status,
158+
)
159+
160+
161+
def create_task_model(
162+
table_name: str = 'tasks', base: type[DeclarativeBase] = Base
163+
) -> type:
164+
"""Create a TaskModel class with a configurable table name.
165+
166+
Args:
167+
table_name: Name of the database table. Defaults to 'tasks'.
168+
base: Base declarative class to use. Defaults to the SDK's Base class.
169+
170+
Returns:
171+
TaskModel class with the specified table name.
172+
173+
Example:
174+
# Create a task model with default table name
175+
TaskModel = create_task_model()
176+
177+
# Create a task model with custom table name
178+
CustomTaskModel = create_task_model('my_tasks')
179+
180+
# Use with a custom base
181+
from myapp.database import Base as MyBase
182+
TaskModel = create_task_model('tasks', MyBase)
183+
"""
184+
185+
class TaskModel(TaskMixin, base):
186+
__tablename__ = table_name
187+
188+
@override
189+
def __repr__(self) -> str:
190+
"""Return a string representation of the task."""
191+
repr_template = '<TaskModel[{TABLE}](id="{ID}", contextId="{CTX_ID}", status="{STATUS}")>'
192+
return repr_template.format(
193+
TABLE=table_name,
194+
ID=self.id,
195+
CTX_ID=self.contextId,
196+
STATUS=self.status,
197+
)
198+
199+
# Set a dynamic name for better debugging
200+
TaskModel.__name__ = f'TaskModel_{table_name}'
201+
TaskModel.__qualname__ = f'TaskModel_{table_name}'
202+
203+
return TaskModel
204+
205+
206+
# Default TaskModel for backward compatibility
207+
class TaskModel(TaskMixin, Base):
208+
"""Default task model with standard table name."""
209+
210+
__tablename__ = 'tasks'

0 commit comments

Comments
 (0)