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
271 changes: 271 additions & 0 deletions agent_sdks/go/a2a/a2a.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
package a2a

import (
"encoding/json"
"fmt"
"maps"
"slices"
"strconv"
"strings"
)

const (
A2UIExtensionBaseURI = "https://a2ui.org/a2a-extension/a2ui"
MIMETypeKey = "mimeType"
A2UIMIMETypeV09 = "application/json+a2ui"
A2UIMIMETypeV091 = "application/a2ui+json"
A2UIMIMETypeV010 = "application/a2ui+json"
MIMETypeV09 = A2UIMIMETypeV09
MIMETypeV091 = A2UIMIMETypeV091
MIMETypeV010 = A2UIMIMETypeV010
A2UIMIMEType = A2UIMIMETypeV09
A2UIMIMETypeLatest = A2UIMIMETypeV010
MIMEType = A2UIMIMEType
MIMETypeLatest = A2UIMIMETypeLatest
AcceptsInlineCatalogsKey = "acceptsInlineCatalogs"
SupportedCatalogIDsKey = "supportedCatalogIds"
)

// DataPart is a transport-neutral A2A data part carrying A2UI JSON.
// Its shape matches the official A2A Go SDK's DataPart.
type DataPart struct {
Data map[string]any `json:"data"`
Metadata map[string]any `json:"metadata,omitempty"`
}

// Part is kept as a compatibility alias for earlier versions of this package.
type Part = DataPart

// AgentExtension is a transport-neutral A2A agent extension descriptor.
// Its shape matches the official A2A Go SDK's AgentExtension.
type AgentExtension struct {
Description string `json:"description,omitempty"`
Params map[string]any `json:"params,omitempty"`
Required bool `json:"required,omitempty"`
URI string `json:"uri"`
}

// Extension is kept as a compatibility alias for earlier versions of this package.
type Extension = AgentExtension

// Versioned reports the A2UI protocol version carried by a payload.
type Versioned interface {
VersionString() string
}

// Meta returns the part metadata.
func (p DataPart) Meta() map[string]any {
return p.Metadata
}

// SetMeta sets a metadata entry.
func (p *DataPart) SetMeta(k string, v any) {
if p.Metadata == nil {
p.Metadata = make(map[string]any)
}
p.Metadata[k] = v
}

// MarshalA2UIData marshals payload into an A2A data-part payload.
// A2A data parts carry JSON objects, so payload must encode as a JSON object.
func MarshalA2UIData(payload any) (map[string]any, error) {
if object, ok := payload.(map[string]any); ok {
if object == nil {
return nil, fmt.Errorf("a2a: payload must encode as a JSON object")
}
return maps.Clone(object), nil
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("a2a: marshal payload: %w", err)
}
var object map[string]any
if err := json.Unmarshal(data, &object); err != nil {
return nil, fmt.Errorf("a2a: decode payload object: %w", err)
}
if object == nil {
return nil, fmt.Errorf("a2a: payload must encode as a JSON object")
}
return object, nil
}

// CreateDataPart marshals an A2UI payload into a transport-neutral A2A data part.
func CreateDataPart(payload any) (DataPart, error) {
return CreateDataPartForVersion(payload, "")
}

// CreateDataPartForVersion marshals an A2UI payload using the MIME type for version.
func CreateDataPartForVersion(payload any, version string) (DataPart, error) {
if version == "" {
if versioned, ok := payload.(Versioned); ok {
version = versioned.VersionString()
}
}
data, err := MarshalA2UIData(payload)
if err != nil {
return DataPart{}, err
}
if version == "" {
version, _ = data["version"].(string)
}
part := DataPart{Data: data}
if version == "" {
part.SetMeta(MIMETypeKey, A2UIMIMETypeLatest)
} else {
part.SetMeta(MIMETypeKey, MIMETypeForVersion(version))
}
return part, nil
}

// CreatePart marshals an A2UI payload into a transport-neutral A2A data part.
func CreatePart(payload any) (Part, error) {
return CreateDataPart(payload)
}

// IsA2UIPart reports whether the part carries the A2UI MIME type.
func IsA2UIPart(part DataPart) bool {
return IsPart(part)
}

// IsPart reports whether the part carries the A2UI MIME type.
func IsPart(part DataPart) bool {
if part.Metadata == nil {
return false
}
mimeType, _ := part.Metadata[MIMETypeKey].(string)
return IsA2UIMIMEType(mimeType)
}

// IsA2UIMIMEType reports whether mimeType is a recognized A2UI MIME type.
func IsA2UIMIMEType(mimeType string) bool {
return mimeType == A2UIMIMETypeV09 || mimeType == A2UIMIMETypeV091 || mimeType == A2UIMIMETypeV010
}

// MIMETypeForVersion returns the A2A MIME type used by an A2UI version.
func MIMETypeForVersion(version string) string {
switch normalizeVersion(version) {
case "v0.9":
return A2UIMIMETypeV09
case "v0.9.1":
return A2UIMIMETypeV091
default:
return A2UIMIMETypeLatest
}
}

// A2UIData returns the structured A2UI payload if the part carries A2UI data.
func A2UIData(part DataPart) (map[string]any, bool) {
return Data(part)
}

// Data returns the structured A2UI payload if the part carries A2UI data.
func Data(part DataPart) (map[string]any, bool) {
if !IsPart(part) {
return nil, false
}
return part.Data, true
}

// AgentExtensionOptions configures an A2A agent extension descriptor.
type AgentExtensionOptions struct {
Version string
AcceptsInlineCatalogs bool
SupportedCatalogIDs []string
}

// NewAgentExtension constructs an A2UI extension descriptor.
func NewAgentExtension(opts AgentExtensionOptions) AgentExtension {
params := make(map[string]any)
if opts.AcceptsInlineCatalogs {
params[AcceptsInlineCatalogsKey] = true
}
if len(opts.SupportedCatalogIDs) > 0 {
params[SupportedCatalogIDsKey] = append([]string(nil), opts.SupportedCatalogIDs...)
}
if len(params) == 0 {
params = nil
}
return AgentExtension{
URI: fmt.Sprintf("%s/%s", A2UIExtensionBaseURI, normalizeVersion(opts.Version)),
Description: "Provides agent driven UI using the A2UI JSON format.",
Params: params,
}
}

// NewExtension constructs an A2UI extension descriptor.
func NewExtension(opts AgentExtensionOptions) Extension {
return NewAgentExtension(opts)
}

// SelectNewestRequestedExtension returns the newest requested extension also advertised by the agent.
func SelectNewestRequestedExtension(requested, advertised []string) (string, bool) {
best := ""
for _, candidate := range requested {
if !slices.Contains(advertised, candidate) {
continue
}
if best == "" || compareExtensionVersion(candidate, best) > 0 {
best = candidate
}
}
if best == "" {
return "", false
}
return best, true
}

// TryActivateExtension selects and activates the newest mutually supported extension.
func TryActivateExtension(requested, advertised []string) (activated, version string, ok bool) {
activated, ok = SelectNewestRequestedExtension(requested, advertised)
if !ok {
return "", "", false
}
version = strings.TrimPrefix(activated, A2UIExtensionBaseURI+"/")
version = strings.TrimPrefix(version, "v")
return activated, version, true
}

func normalizeVersion(version string) string {
version = strings.TrimSpace(version)
version = strings.TrimPrefix(version, "v")
if version == "" {
return "v0.9"
}
return "v" + version
}

func compareExtensionVersion(a, b string) int {
av := strings.TrimPrefix(strings.TrimPrefix(a, A2UIExtensionBaseURI+"/"), "v")
bv := strings.TrimPrefix(strings.TrimPrefix(b, A2UIExtensionBaseURI+"/"), "v")
aparts := parseVersionParts(av)
bparts := parseVersionParts(bv)
for i := 0; i < len(aparts) || i < len(bparts); i++ {
var ai, bi int
if i < len(aparts) {
ai = aparts[i]
}
if i < len(bparts) {
bi = bparts[i]
}
switch {
case ai < bi:
return -1
case ai > bi:
return 1
}
}
return 0
}

func parseVersionParts(version string) []int {
fields := strings.Split(version, ".")
out := make([]int, 0, len(fields))
for _, field := range fields {
n, err := strconv.Atoi(field)
if err != nil {
return []int{0}
}
out = append(out, n)
}
return out
}
116 changes: 116 additions & 0 deletions agent_sdks/go/a2a/a2a_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package a2a

import "testing"

func TestCreatePart(t *testing.T) {
part, err := CreateDataPart(map[string]any{"version": "v0.9"})
if err != nil {
t.Fatal(err)
}
if !IsPart(part) {
t.Fatal("expected A2UI part")
}
if _, ok := Data(part); !ok {
t.Fatal("expected A2UI data")
}
if got := part.Metadata[MIMETypeKey]; got != A2UIMIMETypeV09 {
t.Fatalf("mime type = %q, want %q", got, A2UIMIMETypeV09)
}
}

func TestCreatePartUsesVersionedMIMEType(t *testing.T) {
tests := []struct {
name string
version string
want string
}{
{"v0.9", "v0.9", A2UIMIMETypeV09},
{"v0.9.1", "v0.9.1", A2UIMIMETypeV091},
{"v0.10", "v0.10", A2UIMIMETypeV010},
{"default", "", A2UIMIMETypeLatest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
part, err := CreateDataPartForVersion(map[string]any{"version": tt.version}, tt.version)
if err != nil {
t.Fatal(err)
}
if got := part.Metadata[MIMETypeKey]; got != tt.want {
t.Fatalf("mime type = %q, want %q", got, tt.want)
}
if !IsPart(part) {
t.Fatal("expected A2UI part")
}
})
}
}

func TestCreateDataPartInfersVersionedPayload(t *testing.T) {
part, err := CreateDataPart(versionedPayload{Version: "v0.10", Kind: "demo"})
if err != nil {
t.Fatal(err)
}
if got := part.Metadata[MIMETypeKey]; got != A2UIMIMETypeV010 {
t.Fatalf("mime type = %q, want %q", got, A2UIMIMETypeV010)
}
}

func TestMarshalA2UIDataClonesMapPayload(t *testing.T) {
payload := map[string]any{"version": "v0.10"}
data, err := MarshalA2UIData(payload)
if err != nil {
t.Fatal(err)
}
data["version"] = "changed"
if got := payload["version"]; got != "v0.10" {
t.Fatalf("payload version = %q, want unchanged", got)
}
}

func TestCreateDataPartRejectsNonObject(t *testing.T) {
if _, err := CreateDataPart([]string{"not", "an", "object"}); err == nil {
t.Fatal("expected error")
}
}

func TestNewAgentExtension(t *testing.T) {
ext := NewAgentExtension(AgentExtensionOptions{
Version: "0.9",
AcceptsInlineCatalogs: true,
SupportedCatalogIDs: []string{"catalog"},
})
if ext.URI != "https://a2ui.org/a2a-extension/a2ui/v0.9" {
t.Fatalf("uri = %q", ext.URI)
}
if ext.Params[AcceptsInlineCatalogsKey] != true {
t.Fatal("expected acceptsInlineCatalogs param")
}
}

func TestSelectNewestRequestedExtension(t *testing.T) {
got, ok := SelectNewestRequestedExtension(
[]string{
"https://a2ui.org/a2a-extension/a2ui/v0.8",
"https://a2ui.org/a2a-extension/a2ui/v0.9",
},
[]string{
"https://a2ui.org/a2a-extension/a2ui/v0.8",
"https://a2ui.org/a2a-extension/a2ui/v0.9",
},
)
if !ok {
t.Fatal("expected a match")
}
if want := "https://a2ui.org/a2a-extension/a2ui/v0.9"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
}

type versionedPayload struct {
Version string `json:"version"`
Kind string `json:"kind"`
}

func (p versionedPayload) VersionString() string {
return p.Version
}
Loading