Skip to content

Commit 5e58e18

Browse files
committed
Refactor A2A gateway routing and session lifecycle
- simplify gateway request handling and A2UI bootstrap flow - add database-backed task routing and worker protocol types - clean up expired worker child processes - improve GCP gateway architecture and mTLS deployment - expand A2A gateway and worker tests
1 parent 83d6b91 commit 5e58e18

12 files changed

Lines changed: 1898 additions & 365 deletions

File tree

‎gcloud/gateway/README.md‎

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,67 @@
1-
# Dynamic gateway deployment
1+
# Dynamic gateway
22

33
Dynamic gateway mode exposes one public A2A endpoint. Each user dynamically
44
selects an Oracle database connection and Select AI team through the A2UI
55
connection form. A session remains available for 15 minutes by default. Set a
66
different lifetime in seconds with `--session-ttl-seconds`; for example,
77
`--session-ttl-seconds 1800` keeps sessions for 30 minutes.
88

9+
## Protocol architecture
10+
11+
```text
12+
┌──────────────┐ A2A JSON-RPC/HTTP ┌──────────────────┐ ┌────────────────────────────┐
13+
│ A2A client │────────────────────►│ Gateway instances│──── route lookup/update ──────────► │ Service Registry │
14+
└──────────────┘ │ Public A2A API │ │ Service discovery │
15+
│ A2UI bootstrap │ │ Session routes │
16+
└────────┬─────────┘ │ Task routes │
17+
│ Internal protobuf │ │
18+
▼ │ │
19+
┌──────────────────┐ │ │
20+
│ Worker pool │──── registration / heartbeat------->│ │
21+
│ worker-0, ... │ │ │
22+
└────────┬─────────┘ └────────────────────────────┘
23+
│ one child per database session
24+
▼
25+
┌──────────────────┐
26+
│ Session runtime │
27+
│ A2A handler │
28+
│ Task/context │
29+
│ stores │
30+
│ Database session │
31+
└────────┬─────────┘
32+
│ SQL / Select AI
33+
▼
34+
┌──────────────────┐
35+
│ Oracle Database │
36+
└──────────────────┘
37+
```
38+
39+
The gateway is the only public A2A application. It selects a worker through
40+
the Service Registry, opens a session there, and proxies subsequent A2A calls
41+
using the internal protobuf protocol. The selected worker starts one child
42+
runtime for that session. The child owns the database connection,
43+
`DefaultRequestHandler`, `OracleTaskStore`, and `OracleContextStore`.
44+
45+
The Service Registry stores only service-discovery and non-secret
46+
session/task-to-worker metadata. Task payloads and context mappings remain in
47+
Oracle. Connection-form tasks are response-only bootstrap tasks: they are
48+
created by the gateway before a database session exists and are not persisted
49+
or routed.
50+
51+
## GCP deployment
52+
53+
The protocol architecture above is implemented on GCP as follows:
54+
955
```text
1056
┌──────────────────────┐
1157
│ A2A / Gemini client │
1258
└──────────┬───────────┘
1359
│ public A2A
1460
v
15-
┌──────────────────────┐
16-
│ Cloud Run gateway │
17-
└──────┬───────┬───────┘
61+
┌────────────────────────────┐
62+
│ Cloud Run gateway │
63+
│ A2A proxy + form bootstrap │
64+
└──────┬───────────┬─────────┘
1865
│ │ private VPC: mTLS request to worker hostname
1966
│ │
2067
│ │ ┌─────────────────────── GKE ───────────────────────┐
@@ -23,19 +70,16 @@ different lifetime in seconds with `--session-ttl-seconds`; for example,
2370
│ │ │ │
2471
│ │ v │
2572
│ │ [StatefulSet worker-0 / worker-1 / ...] │
26-
│ │ session child process → Oracle Database │
73+
│ │ session child: A2A handler + Oracle stores │
74+
│ │ │ │
75+
│ │ v │
76+
│ │ Oracle Database │
2777
│ │ │
2878
│ │ [Consul] │
2979
└────────────>│ selects healthy worker; returns worker hostname │
3080
└───────────────────────────────────────────────────┘
3181
```
3282

33-
The gateway is the only public A2A application. Consul and workers are a GKE
34-
clustered service: Consul selects a worker for each new dynamic session, and
35-
the chosen worker retains that session's process and Oracle conversation.
36-
37-
## Deploy the complete stack
38-
3983
Run this from the repository root:
4084

4185
```bash
@@ -68,10 +112,14 @@ gcloud/gateway/deploy.sh \
68112
```
69113

70114
The Cloud Run gateway uses direct VPC egress to reach the internal Consul load
71-
balancer and GKE worker pod addresses. The default one-instance gateway limit
72-
is intentional: gateway A2A task and context/session state is currently in
73-
memory. Workers, rather than the gateway, provide the clustered capacity for
74-
dynamic sessions.
115+
balancer and GKE worker pod addresses. The gateway keeps only the connection
116+
form task transiently, before a database session exists. Connected task and
117+
context state is stored in Oracle on the selected worker. Workers, rather
118+
than the gateway, provide the clustered capacity for dynamic sessions.
119+
The deployment currently keeps one gateway instance as an operational default;
120+
the gateway does not cache forms or connected task/context state. Gateway
121+
scaling does not change session affinity because Consul stores the session and
122+
task routes.
75123

76124
`cloudbuild.yaml` is the complete build and deployment workflow. It supplies
77125
the generated image and Consul endpoint values to the Cloud Run gateway at

‎gcloud/gateway/deploy.sh‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,15 @@ if [[ "$enable_worker_mtls" == "true" ]]; then
166166
fi
167167
done
168168
if [[ "$create_mtls_material" != "true" ]]; then
169-
existing_worker_certificate_sans="$(gcloud secrets versions access latest \
170-
--secret=select-ai-worker-mtls-cert --project="$project_id" 2>/dev/null | \
171-
openssl x509 -noout -ext subjectAltName 2>/dev/null || true)"
172-
if [[ "$existing_worker_certificate_sans" != *"DNS:$worker_certificate_dns_name"* ]]; then
169+
# macOS ships LibreSSL, which does not support x509's -ext option.
170+
# Read the portable text representation and perform a literal SAN check.
171+
existing_worker_certificate_text="$(
172+
gcloud secrets versions access latest \
173+
--secret=select-ai-worker-mtls-cert --project="$project_id" 2>/dev/null | \
174+
openssl x509 -noout -text 2>/dev/null || true
175+
)"
176+
if ! printf '%s\n' "$existing_worker_certificate_text" | \
177+
grep -F -- "DNS:$worker_certificate_dns_name" >/dev/null; then
173178
create_mtls_material="true"
174179
echo "Replacing mTLS material because the worker certificate does not"
175180
echo "match the GKE DNS domain."

‎src/select_ai/agent/a2a/a2ui.py‎

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,34 @@
55
# https://oss.oracle.com/licenses/upl.
66
# -----------------------------------------------------------------------------
77

8-
"""Shared A2UI protocol declarations for Select AI A2A agents."""
8+
"""Shared A2UI protocol helpers for Select AI A2A agents."""
99

10+
from __future__ import annotations
11+
12+
from collections.abc import Iterator
13+
14+
from a2a.helpers import new_data_part
1015
from a2a.types import AgentExtension
11-
from google.protobuf.json_format import ParseDict
16+
from a2a.types.a2a_pb2 import Message, Part
17+
from google.protobuf.json_format import MessageToDict, ParseDict
1218
from google.protobuf.struct_pb2 import Struct
1319

14-
from select_ai.agent.a2a.forms import _CATALOG
15-
16-
A2UI_EXTENSION_URI = "https://a2ui.org/a2a-extension/a2ui/v0.9"
20+
A2UI_VERSION = "v0.9"
21+
A2UI_EXTENSION_URI = f"https://a2ui.org/a2a-extension/a2ui/{A2UI_VERSION}"
1722
A2UI_MIME_TYPE = "application/json+a2ui"
23+
A2UI_CATALOG_ID = (
24+
"https://www.gstatic.com/vertexaisearch/a2ui/"
25+
f"{A2UI_VERSION.replace('.', '_')}/"
26+
"gemini_enterprise_composite_catalog.json"
27+
)
1828

1929

2030
def a2ui_extension() -> AgentExtension:
21-
"""Return the A2UI v0.9 capability used by Gemini Enterprise."""
31+
"""Return the supported A2UI capability used by Gemini Enterprise."""
2232
params = ParseDict(
2333
{
2434
"acceptsInlineCatalogs": True,
25-
"supportedCatalogIds": [_CATALOG],
35+
"supportedCatalogIds": [A2UI_CATALOG_ID],
2636
},
2737
Struct(),
2838
)
@@ -31,3 +41,57 @@ def a2ui_extension() -> AgentExtension:
3141
description="Provides agent driven UI using the A2UI JSON format.",
3242
params=params,
3343
)
44+
45+
46+
def a2ui_part(operation: dict) -> Part:
47+
"""Encode one A2UI operation in an A2A data part."""
48+
part = new_data_part(operation)
49+
ParseDict({"mimeType": A2UI_MIME_TYPE}, part.metadata)
50+
return part
51+
52+
53+
def a2ui_operations(message: Message) -> Iterator[dict]:
54+
"""Yield operations from data parts marked with the A2UI MIME type."""
55+
for part in message.parts:
56+
if not _is_a2ui_part(part):
57+
continue
58+
data = MessageToDict(part.data)
59+
operations = data if isinstance(data, list) else [data]
60+
yield from (
61+
operation
62+
for operation in operations
63+
if isinstance(operation, dict)
64+
)
65+
66+
67+
def find_action(message: Message, name: str) -> dict | None:
68+
"""Return a named A2UI action, including Gemini's unmarked input form."""
69+
for part in message.parts:
70+
if part.WhichOneof("content") != "data":
71+
continue
72+
mime_type = MessageToDict(part.metadata).get("mimeType")
73+
# Gemini Enterprise does not currently echo the A2UI MIME metadata on
74+
# a submitted form action. Accept that legacy input shape, while
75+
# still rejecting data parts explicitly marked as another format.
76+
if mime_type not in (None, A2UI_MIME_TYPE):
77+
continue
78+
data = MessageToDict(part.data)
79+
operations = data if isinstance(data, list) else [data]
80+
for operation in operations:
81+
if (
82+
not isinstance(operation, dict)
83+
or operation.get("version") != A2UI_VERSION
84+
):
85+
continue
86+
action = operation.get("action")
87+
if isinstance(action, dict) and action.get("name") == name:
88+
return action
89+
return None
90+
91+
92+
def _is_a2ui_part(part: Part) -> bool:
93+
"""Identify an A2UI data part by its standard MIME type."""
94+
return (
95+
part.WhichOneof("content") == "data"
96+
and MessageToDict(part.metadata).get("mimeType") == A2UI_MIME_TYPE
97+
)

‎src/select_ai/agent/a2a/forms.py‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,30 @@
77

88
"""A2UI connection form emitted by the public gateway."""
99

10+
from uuid import uuid4
1011

11-
_CATALOG = (
12-
"https://www.gstatic.com/vertexaisearch/a2ui/v0_9/"
13-
"gemini_enterprise_composite_catalog.json"
14-
)
12+
from select_ai.agent.a2a.a2ui import A2UI_CATALOG_ID, A2UI_VERSION
1513

1614

17-
def connection_form() -> list[dict]:
15+
def connection_form(surface_id: str | None = None) -> list[dict]:
1816
"""Return the non-persistent database connection form."""
17+
# A2UI surface IDs must be globally unique for the renderer's lifetime.
18+
# Gemini retains surfaces for an A2A conversation after the connection
19+
# form is submitted, so reusing a fixed ID prevents a reconnect form from
20+
# being created in that same conversation.
21+
surface_id = surface_id or f"db-connect-{uuid4().hex}"
1922
return [
2023
{
21-
"version": "v0.9",
24+
"version": A2UI_VERSION,
2225
"createSurface": {
23-
"surfaceId": "db-connect",
24-
"catalogId": _CATALOG,
26+
"surfaceId": surface_id,
27+
"catalogId": A2UI_CATALOG_ID,
2528
},
2629
},
2730
{
28-
"version": "v0.9",
31+
"version": A2UI_VERSION,
2932
"updateComponents": {
30-
"surfaceId": "db-connect",
33+
"surfaceId": surface_id,
3134
"components": [
3235
{"id": "root", "component": "Card", "child": "column"},
3336
{
@@ -102,9 +105,9 @@ def connection_form() -> list[dict]:
102105
},
103106
},
104107
{
105-
"version": "v0.9",
108+
"version": A2UI_VERSION,
106109
"updateDataModel": {
107-
"surfaceId": "db-connect",
110+
"surfaceId": surface_id,
108111
"path": "/",
109112
"value": {
110113
"dsn": "",

0 commit comments

Comments
 (0)