Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions .design/a2a-sdk-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# A2A Go SDK Migration

## Status: In Progress
## Date: 2026-06-08

## Summary

Migrate the scion-a2a-bridge from a hand-rolled A2A protocol implementation to
the official `a2a-go` SDK (`github.com/a2aproject/a2a-go/v2`). This replaces
our custom JSON-RPC handling, task lifecycle management, and streaming
infrastructure with the SDK's spec-compliant implementations while preserving
our Scion Hub routing core.

## Motivation

- **Spec compliance**: The SDK tracks the A2A spec automatically. Our hand-rolled
implementation required manual updates for each spec revision.
- **Reduced maintenance**: ~500 lines of JSON-RPC, SSE streaming, and task store
code replaced by SDK.
- **Multi-transport**: SDK provides JSON-RPC, REST, and gRPC transports from a
single `RequestHandler` — we get gRPC and REST nearly for free.
- **Correctness**: SDK handles edge cases (OCC, concurrent cancellation, event
ordering) that our MVP implementation simplified or deferred.

## Architecture

### Before (hand-rolled)

```
HTTP Request → server.go (JSON-RPC dispatch) → bridge.go (task management)
→ Hub API → Broker → bridge.go (response correlation) → JSON-RPC response
```

### After (SDK-based)

```
HTTP Request → auth middleware → route extraction → SDK JSONRPC Handler
→ SDK RequestHandler → SDK task lifecycle → ScionExecutor.Execute()
→ bridge.go (Hub routing) → Broker → waiter channel → SDK events
→ SDK response serialization → HTTP response
```

### Key Components

**ScionExecutor** (`executor.go`): Implements `a2asrv.AgentExecutor`. The bridge
between the SDK's event-driven model and our Scion Hub message routing.

- `Execute()`: Translates SDK message → Scion StructuredMessage, sends to Hub,
waits for broker response, yields SDK events.
- `Cancel()`: Sends interrupt to Scion agent, yields canceled status event.

**Server** (`server.go`): Simplified HTTP routing layer. Handles:
- Multi-project/agent URL routing (`/projects/{p}/agents/{a}/jsonrpc`)
- Agent card serving (kept custom — SDK's card handler is single-agent)
- Auth middleware, rate limiting, metrics (unchanged)
- Delegates JSON-RPC to SDK's `NewJSONRPCHandler`

**Bridge** (`bridge.go`): Core Hub routing preserved. Changes:
- Added `sdkRequestHandler` field for multi-transport access
- Task lifecycle now managed by SDK's in-memory task store
- SQLite store retained for context mapping and broker correlation

**Translate** (`translate.go`): Added SDK-compatible translation functions:
- `TranslateA2APartsToScion()`: SDK `a2a.ContentParts` → Scion message
- `TranslateScionToA2AParts()`: Scion message → SDK `a2a.Message` + `a2a.Artifact`
- `MapActivityToSDKTaskState()`: Scion activity → SDK `a2a.TaskState`
- Original functions retained for backward compatibility

## What Changed

| Component | Before | After |
|-----------|--------|-------|
| JSON-RPC parsing | `server.go` hand-rolled | SDK `a2asrv.NewJSONRPCHandler` |
| Task lifecycle | `bridge.go` + SQLite | SDK in-memory task store |
| SSE streaming | `stream.go` custom | SDK built-in |
| Push notifications | `push.go` custom | SDK `push.Sender` (future) |
| A2A types | `translate.go` custom structs | SDK `a2a` package |
| Error codes | Custom constants | SDK `a2a.Err*` sentinel errors |

## What's Preserved

- **Bridge core**: Hub client routing, broker plugin, agent lookup, context
resolution, auto-provisioning — all unchanged.
- **Config**: Same YAML format, same fields.
- **Auth**: Same API key / Bearer middleware.
- **Metrics**: Same Prometheus metrics.
- **Rate limiting**: Same per-IP/key token bucket.
- **Broker plugin**: Same go-plugin RPC server.
- **SQLite store**: Retained for context mapping. Task state now also in SDK
in-memory store.

## PR Structure

### PR A: SDK Adoption (`a2a/sdk-migration`)
- Add `a2a-go/v2` dependency
- New `executor.go` (AgentExecutor implementation)
- Rewritten `server.go` (SDK handler delegation)
- Updated `translate.go` (SDK type translations)
- Updated `bridge.go` (sdkRequestHandler field)
- Updated `main.go` (SDK wiring)
- Updated tests

### PR B: gRPC + REST Transports (`a2a/sdk-grpc-rest`)
- `a2agrpc.NewHandler` for gRPC transport
- `a2asrv.NewRESTHandler` for REST transport
- Config fields: `grpc_listen_address`, `rest_listen_address`
- Startup wiring in `main.go`

## Migration Risks

1. **Task store divergence**: SDK uses in-memory store; our SQLite store tracks
context mappings separately. Tasks visible via A2A protocol come from SDK
store; context lookups use SQLite.

2. **Broker correlation**: The SDK doesn't know about our broker. Response
correlation happens inside `ScionExecutor.Execute()` using the same waiter
channel pattern.

3. **Push notification gap**: SDK has `push.Sender` interface but we haven't
wired our SSRF-safe push dispatcher yet. Push is disabled in capabilities.

## Future Work

- Wire SDK push notification support with our SSRF-safe dispatcher
- Implement SDK `taskstore.Store` interface backed by SQLite for persistence
- Add multi-turn conversation support (SDK handles it; our executor needs updates)
- Evaluate SDK's work queue for distributed deployment
38 changes: 36 additions & 2 deletions extras/scion-a2a-bridge/cmd/scion-a2a-bridge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import (

secretmanager "cloud.google.com/go/secretmanager/apiv1"
smpb "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
"github.com/a2aproject/a2a-go/v2/a2a"
"github.com/a2aproject/a2a-go/v2/a2asrv"
"github.com/a2aproject/a2a-go/v2/a2asrv/taskstore"
"github.com/prometheus/client_golang/prometheus"
"gopkg.in/yaml.v3"

Expand Down Expand Up @@ -136,13 +139,41 @@ func main() {
// Wire broker into the bridge for subscription management.
b.SetBroker(broker)

// Create SDK executor and request handler.
// Use a route-key authenticator so the in-memory task store associates tasks
// with the correct project/agent pair, and a ScopedTaskStore wrapper that
// enforces ownership on Get/Update to prevent cross-tenant access.
executor := bridge.NewScionExecutor(b, log.With("component", "executor"))
routeAuthenticator := bridge.RouteKeyAuthenticator()
innerTaskStore := taskstore.NewInMemory(&taskstore.InMemoryStoreConfig{
Authenticator: routeAuthenticator,
})
scopedTaskStore := bridge.NewScopedTaskStore(innerTaskStore)
sdkRequestHandler := a2asrv.NewHandler(
executor,
a2asrv.WithLogger(log.With("component", "a2a-sdk")),
a2asrv.WithCapabilityChecks(&a2a.AgentCapabilities{
Streaming: true,
PushNotifications: false,
}),
a2asrv.WithAgentInactivityTimeout(cfg.Timeouts.SendMessage),
a2asrv.WithTaskStore(scopedTaskStore),
)
b.SetSDKRequestHandler(sdkRequestHandler)

// Create SDK JSON-RPC transport handler.
sdkJSONRPCHandler := a2asrv.NewJSONRPCHandler(
sdkRequestHandler,
a2asrv.WithTransportKeepAlive(cfg.Timeouts.SSEKeepalive),
)

// Start A2A HTTP server.
listenAddr := cfg.Bridge.ListenAddress
if listenAddr == "" {
listenAddr = ":8443"
}

srv := bridge.NewServer(b, cfg, metrics, log.With("component", "a2a-server"))
srv := bridge.NewServer(b, cfg, metrics, log.With("component", "a2a-server"), sdkJSONRPCHandler)
srv.WarnOnOpenAuth()

httpServer := &http.Server{
Expand All @@ -163,7 +194,10 @@ func main() {
}
}()

log.Info("scion-a2a-bridge ready")
log.Info("scion-a2a-bridge ready",
"transport", "JSON-RPC",
"sdk", "a2a-go/v2",
)

// Wait for shutdown signal.
sigCh := make(chan os.Signal, 1)
Expand Down
5 changes: 3 additions & 2 deletions extras/scion-a2a-bridge/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26.1
require (
cloud.google.com/go/secretmanager v1.16.0
github.com/GoogleCloudPlatform/scion v0.0.0-00010101000000-000000000000
github.com/a2aproject/a2a-go/v2 v2.3.1
github.com/go-jose/go-jose/v4 v4.1.4
github.com/google/uuid v1.6.0
github.com/hashicorp/go-plugin v1.7.0
Expand Down Expand Up @@ -54,8 +55,8 @@ require (
golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.259.0 // indirect
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
Expand Down
12 changes: 8 additions & 4 deletions extras/scion-a2a-bridge/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
cloud.google.com/go/secretmanager v1.16.0 h1:19QT7ZsLJ8FSP1k+4esQvuCD7npMJml6hYzilxVyT+k=
cloud.google.com/go/secretmanager v1.16.0/go.mod h1://C/e4I8D26SDTz1f3TQcddhcmiC3rMEl0S1Cakvs3Q=
github.com/a2aproject/a2a-go/v2 v2.3.1 h1:QWMdOX2UsJ8BJmjs952eo1FRyGsOVl0gFCKeM76AgGE=
github.com/a2aproject/a2a-go/v2 v2.3.1/go.mod h1:mkZr8y2bUgAVQsjs/5fHK7xrRlAHDybMEyxWh2tKRC8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
Expand Down Expand Up @@ -122,6 +124,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
Expand All @@ -148,10 +152,10 @@ google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ=
google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo=
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
Expand Down
11 changes: 10 additions & 1 deletion extras/scion-a2a-bridge/internal/bridge/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"sync"
"time"

"github.com/a2aproject/a2a-go/v2/a2asrv"
"github.com/google/uuid"

"github.com/GoogleCloudPlatform/scion/extras/scion-a2a-bridge/internal/identity"
Expand Down Expand Up @@ -58,6 +59,9 @@ type Bridge struct {
metrics *Metrics
log *slog.Logger

// sdkRequestHandler holds the SDK RequestHandler for multi-transport use (gRPC, REST).
sdkRequestHandler a2asrv.RequestHandler

// waiters tracks channels waiting for agent responses, keyed by taskID.
mu sync.RWMutex
waiters map[string]*waiter
Expand Down Expand Up @@ -229,6 +233,11 @@ func (b *Bridge) SetBroker(broker *BrokerServer) {
b.broker = broker
}

// SetSDKRequestHandler stores the SDK RequestHandler for multi-transport access.
func (b *Bridge) SetSDKRequestHandler(h a2asrv.RequestHandler) {
b.sdkRequestHandler = h
}

// agentKey returns a composite key for project-scoped agent isolation.
func agentKey(projectID, agentSlug string) string {
return projectID + ":" + agentSlug
Expand Down Expand Up @@ -878,7 +887,7 @@ func (b *Bridge) GenerateAgentCard(ctx context.Context, projectSlug, agentSlug s
"version": "1.0.0",
"capabilities": map[string]bool{
"streaming": true,
"pushNotifications": true,
"pushNotifications": false,
},
"defaultInputModes": []string{"text/plain", "application/json"},
"defaultOutputModes": []string{"text/plain", "application/json"},
Expand Down
Loading