diff --git a/a2a_agents/go/README.md b/a2a_agents/go/README.md new file mode 100644 index 000000000..58e7aab4d --- /dev/null +++ b/a2a_agents/go/README.md @@ -0,0 +1,122 @@ +# A2UI Agent Go SDK + +This directory contains the Go implementation of the A2UI agent library, +enabling agents to "speak UI" using the A2UI protocol. + +## Overview + +The `a2ui` package provides the core infrastructure for an agent to: + +1. **Declare Capability**: Signal support for the A2UI extension ( + `https://a2ui.org/a2a-extension/a2ui/v0.8`). +2. **Validate Payloads**: Ensure generated A2UI JSON conforms to the required + schema before sending. +3. **Transport UI**: Encapsulate A2UI payloads within A2A `DataPart`s for + transport to the client. + +## Components + +The SDK mirrors the structure of the reference Python implementation: + +* **`a2ui.go`**: Core constants (`ExtensionURI`, MIME types), types, and helper + functions for extension management (`GetA2UIAgentExtension`, + `TryActivateA2UIExtension`) and A2UI part manipulation (`CreateA2UIPart`, + `IsA2UIPart`, `GetA2UIDataPart`). +* **`schema.go`**: Utilities for A2UI schema manipulation, specifically wrapping + the schema in a JSON array to support streaming lists of components. +* **`toolset.go`**: + * **`SendA2UIToClientToolset`**: Manages the A2UI toolset lifecycle. + * **`SendA2UIJsonToClientTool`**: A tool exposed to the LLM that validates + generated JSON against the provided schema using + `github.com/santhosh-tekuri/jsonschema/v5` and prepares it for sending. + * **`ConvertSendA2UIToClientGenAIPartToA2APart`**: A helper to convert LLM + tool responses into A2A `Part`s. + +## Dependencies + +* **A2A Protocol**: Uses the [A2A Go SDK](https://github.com/a2aproject/a2a-go) + for core definitions (`Part`, `DataPart`, `AgentExtension`, etc.). +* **JSON Schema Validation**: Uses `github.com/santhosh-tekuri/jsonschema/v5` + for robust runtime validation of agent-generated UI. + +## Usage + +### Initializing the Toolset + +```go +import ( +"github.com/google/A2UI/a2a_agents/go/a2ui" +// ... other imports +) + +// Define a provider for your A2UI schema (e.g., loaded from a file) +schemaProvider := func (ctx context.Context) (map[string]interface{}, error) { +return loadMySchema(), nil +} + +// Check if A2UI should be enabled for this request +enabledProvider := func (ctx context.Context) (bool, error) { +// Logic to check if the client supports A2UI (e.g., checking requested extensions) +return a2ui.TryActivateA2UIExtension(ctx), nil +} + +// Create the toolset +toolset := a2ui.NewSendA2UIToClientToolset( +a2ui.A2UIEnabledProvider(enabledProvider), +a2ui.A2UISchemaProvider(schemaProvider), +) + +// Get the tools to register with your LLM agent +tools, err := toolset.GetTools(ctx) +if err != nil { +// handle error +} +``` + +## Building the SDK + +To build the SDK, run the following command from the `a2a_agents/go` directory: + +```bash +go build ./a2ui +``` + +This will compile the `a2ui` package and report any syntax or dependency errors. +Since this is a library, it will not produce an executable binary. + +## Running Tests + +To run the test suite from the `a2a_agents/go` directory: + +```bash +go test -v ./a2ui +``` + +## Disclaimer + +Important: The sample code provided is for demonstration purposes and +illustrates the mechanics of A2UI and the Agent-to-Agent (A2A) protocol. When +building production applications, it is critical to treat any agent operating +outside of your direct control as a potentially untrusted entity. + +All operational data received from an external agent—including its AgentCard, +messages, artifacts, and task statuses—should be handled as untrusted input. For +example, a malicious agent could provide crafted data in its fields (e.g., name, +skills.description) that, if used without sanitization to construct prompts for +a Large Language Model (LLM), could expose your application to prompt injection +attacks. + +Similarly, any UI definition or data stream received must be treated as +untrusted. Malicious agents could attempt to spoof legitimate interfaces to +deceive users (phishing), inject malicious scripts via property values (XSS), or +generate excessive layout complexity to degrade client performance (DoS). If +your application supports optional embedded content (such as iframes or web +views), additional care must be taken to prevent exposure to malicious external +sites. + +Developer Responsibility: Failure to properly validate data and strictly sandbox +rendered content can introduce severe vulnerabilities. Developers are +responsible for implementing appropriate security measures—such as input +sanitization, Content Security Policies (CSP), strict isolation for optional +embedded content, and secure credential handling—to protect their systems and +users. diff --git a/a2a_agents/go/a2ui/a2ui.go b/a2a_agents/go/a2ui/a2ui.go new file mode 100644 index 000000000..bed7f5797 --- /dev/null +++ b/a2a_agents/go/a2ui/a2ui.go @@ -0,0 +1,115 @@ +package a2ui + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "context" + "fmt" + "log" + + "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/a2asrv" +) + +const ( + // ExtensionURI is the URI for the A2UI extension. + ExtensionURI = "https://a2ui.org/a2a-extension/a2ui/v0.8" + + // MIMETypeKey is the key for the MIME type in metadata. + MIMETypeKey = "mimeType" + // MIMEType is the MIME type for A2UI data. + MIMEType = "application/json+a2ui" + + // ClientCapabilitiesKey is the key for A2UI client capabilities. + ClientCapabilitiesKey = "a2uiClientCapabilities" + // SupportedCatalogIDsKey is the key for supported catalog IDs. + SupportedCatalogIDsKey = "supportedCatalogIds" + // InlineCatalogsKey is the key for inline catalogs. + InlineCatalogsKey = "inlineCatalogs" + + // StandardCatalogID is the ID for the standard catalog. + StandardCatalogID = "https://github.com/google/A2UI/blob/main/specification/v0_8/json/standard_catalog_definition.json" + + // AgentExtensionSupportedCatalogIDsKey is the parameter key for supported catalogs in the agent extension. + AgentExtensionSupportedCatalogIDsKey = "supportedCatalogIds" + // AgentExtensionAcceptsInlineCatalogsKey is the parameter key for accepting inline catalogs. + AgentExtensionAcceptsInlineCatalogsKey = "acceptsInlineCatalogs" +) + +// CreateA2UIPart creates an A2A Part containing A2UI data. +func CreateA2UIPart(a2uiData map[string]interface{}) a2a.Part { + return &a2a.DataPart{ + Data: a2uiData, + Metadata: map[string]interface{}{ + MIMETypeKey: MIMEType, + }, + } +} + +// GetA2UIDataPart extracts the DataPart containing A2UI data from an A2A Part, if present. +func GetA2UIDataPart(part a2a.Part) (*a2a.DataPart, error) { + dp, ok := part.(*a2a.DataPart) + if !ok { + return nil, fmt.Errorf("part is not a DataPart") + } + if dp.Metadata != nil && dp.Metadata[MIMETypeKey] == MIMEType { + return dp, nil + } + return nil, fmt.Errorf("part is not an A2UI part") +} + +// GetA2UIAgentExtension creates the A2UI AgentExtension configuration. +func GetA2UIAgentExtension(acceptsInlineCatalogs bool, supportedCatalogIDs []string) *a2a.AgentExtension { + params := make(map[string]interface{}) + + if acceptsInlineCatalogs { + params[AgentExtensionAcceptsInlineCatalogsKey] = true + } + + if len(supportedCatalogIDs) > 0 { + params[AgentExtensionSupportedCatalogIDsKey] = supportedCatalogIDs + } + + var paramsOrNil map[string]interface{} + if len(params) > 0 { + paramsOrNil = params + } + + return &a2a.AgentExtension{ + URI: ExtensionURI, + Description: "Provides agent driven UI using the A2UI JSON format.", + Params: paramsOrNil, + } +} + +// TryActivateA2UIExtension activates the A2UI extension if requested. +func TryActivateA2UIExtension(ctx context.Context) bool { + exts, ok := a2asrv.ExtensionsFrom(ctx) + if !ok { + log.Println("TryActivateA2UIExtension: No extensions found in context") + return false + } + + a2uiExt := &a2a.AgentExtension{URI: ExtensionURI} + requested := exts.Requested(a2uiExt) + + log.Printf("TryActivateA2UIExtension: Checking URI %s. Requested: %v", ExtensionURI, requested) + + if requested { + exts.Activate(a2uiExt) + return true + } + return false +} diff --git a/a2a_agents/go/a2ui/a2ui_test.go b/a2a_agents/go/a2ui/a2ui_test.go new file mode 100644 index 000000000..208c298c1 --- /dev/null +++ b/a2a_agents/go/a2ui/a2ui_test.go @@ -0,0 +1,322 @@ +package a2ui + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "context" + "strings" + "testing" + + "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/a2asrv" +) + +func TestA2UIPartSerialization(t *testing.T) { + a2uiData := map[string]interface{}{ + "beginRendering": map[string]interface{}{ + "surfaceId": "test-surface", + "root": "root-column", + }, + } + + part := CreateA2UIPart(a2uiData) + + if dataPart, _ := GetA2UIDataPart(part); dataPart == nil { + t.Error("Should be identified as A2UI part") + } + + dataPart, err := GetA2UIDataPart(part) + if err != nil { + t.Errorf("Should contain DataPart: %v", err) + } + + // Compare data + if dataPart.Data["beginRendering"].(map[string]interface{})["surfaceId"] != "test-surface" { + t.Error("Deserialized data mismatch") + } +} + +func TestNonA2UIDataPart(t *testing.T) { + part := &a2a.DataPart{ + Data: map[string]interface{}{"foo": "bar"}, + Metadata: map[string]interface{}{ + "mimeType": "application/json", + }, + } + + if dataPart, _ := GetA2UIDataPart(part); dataPart != nil { + t.Error("Should not be identified as A2UI part") + } + + if _, err := GetA2UIDataPart(part); err == nil { + t.Error("Should not return A2UI DataPart") + } +} + +func TestGetA2UIAgentExtension(t *testing.T) { + ext := GetA2UIAgentExtension(false, nil) + if ext.URI != ExtensionURI { + t.Errorf("Expected URI %s, got %s", ExtensionURI, ext.URI) + } + if ext.Params != nil { + t.Error("Expected nil params") + } + + supported := []string{"cat1", "cat2"} + ext = GetA2UIAgentExtension(true, supported) + if ext.Params[AgentExtensionAcceptsInlineCatalogsKey] != true { + t.Error("Expected acceptsInlineCatalogs to be true") + } + if len(ext.Params[AgentExtensionSupportedCatalogIDsKey].([]string)) != 2 { + t.Error("Expected 2 supported catalogs") + } +} + +func TestTryActivateA2UIExtension(t *testing.T) { + // Setup context with extensions + ctx := context.Background() + reqMeta := a2asrv.NewRequestMeta(map[string][]string{ + a2asrv.ExtensionsMetaKey: {ExtensionURI}, + }) + ctx, _ = a2asrv.WithCallContext(ctx, reqMeta) + + activated := TryActivateA2UIExtension(ctx) + if !activated { + t.Error("Expected extension to be activated") + } + + exts, _ := a2asrv.ExtensionsFrom(ctx) + if !exts.Active(&a2a.AgentExtension{URI: ExtensionURI}) { + t.Error("Expected A2UI extension to be active") + } + + // Test not requested + ctx2 := context.Background() + ctx2, _ = a2asrv.WithCallContext(ctx2, a2asrv.NewRequestMeta(nil)) + if TryActivateA2UIExtension(ctx2) { + t.Error("Expected extension not to be activated") + } +} + +func TestWrapAsJSONArray(t *testing.T) { + schema := map[string]interface{}{"type": "object"} + wrapped, err := WrapAsJSONArray(schema) + if err != nil { + t.Fatalf("WrapAsJSONArray failed: %v", err) + } + + if wrapped["type"] != "array" { + t.Error("Expected type array") + } + if wrapped["items"] == nil { + t.Error("Expected items field") + } + + // Test Empty Schema + _, err = WrapAsJSONArray(map[string]interface{}{}) + if err == nil { + t.Error("Expected error for empty schema") + } +} + +// Test SendA2UIToClientToolset +func TestToolsetGetTools(t *testing.T) { + // Test Enabled + toolset := NewSendA2UIToClientToolset(true, map[string]interface{}{}) + tools, _ := toolset.GetTools(context.Background()) + if len(tools) != 1 { + t.Error("Expected 1 tool when enabled") + } + + // Test Disabled + toolset = NewSendA2UIToClientToolset(false, map[string]interface{}{}) + tools, _ = toolset.GetTools(context.Background()) + if len(tools) != 0 { + t.Error("Expected 0 tools when disabled") + } + + // Test Provider + provider := func(ctx context.Context) (bool, error) { return true, nil } + toolset = NewSendA2UIToClientToolset(A2UIEnabledProvider(provider), map[string]interface{}{}) + tools, _ = toolset.GetTools(context.Background()) + if len(tools) != 1 { + t.Error("Expected 1 tool with provider returning true") + } +} + +func TestSendA2UIJsonToClientTool_GetDeclaration(t *testing.T) { + schema := map[string]interface{}{"type": "object"} + tool := NewSendA2UIJsonToClientTool(schema) + decl := tool.GetDeclaration() + + if decl.Name != "send_a2ui_json_to_client" { + t.Errorf("Expected tool name send_a2ui_json_to_client, got %s", decl.Name) + } + props := decl.Parameters["properties"].(map[string]interface{}) + if _, ok := props["a2ui_json"]; !ok { + t.Error("Expected a2ui_json property") + } +} + +func TestSendA2UIJsonToClientTool_ProcessLLMRequest(t *testing.T) { + schema := map[string]interface{}{"type": "object"} + tool := NewSendA2UIJsonToClientTool(schema) + llmReq := &LlmRequest{} + + err := tool.ProcessLLMRequest(context.Background(), nil, llmReq) + if err != nil { + t.Fatalf("ProcessLLMRequest failed: %v", err) + } + + if len(llmReq.Instructions) == 0 { + t.Error("Expected instructions to be appended") + } + if !strings.Contains(llmReq.Instructions[0], "---BEGIN A2UI JSON SCHEMA---") { + t.Error("Expected A2UI schema instruction") + } +} + +func TestSendA2UIJsonToClientTool_Run(t *testing.T) { + // Define a simple schema that requires a "text" field + schema := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "text": map[string]interface{}{"type": "string"}, + }, + "required": []string{"text"}, + } + tool := NewSendA2UIJsonToClientTool(schema) + + // Valid JSON list + validJSON := `[{"text": "Hello"}]` + args := map[string]interface{}{"a2ui_json": validJSON} + ctx := &ToolContext{Actions: ToolActions{}} + + result, _ := tool.Run(context.Background(), args, ctx) + if result["validated_a2ui_json"] == nil { + t.Error("Expected validated_a2ui_json in result") + } + if !ctx.Actions.SkipSummarization { + t.Error("Expected SkipSummarization to be true") + } + + // Valid Single Object (should wrap) + validJSONSingle := `{"text": "Hello"}` + args = map[string]interface{}{"a2ui_json": validJSONSingle} + result, _ = tool.Run(context.Background(), args, ctx) + list, ok := result["validated_a2ui_json"].([]interface{}) + if !ok || len(list) != 1 { + t.Error("Expected wrapped list") + } + + // Invalid JSON (missing required field "text") + invalidJSON := `[{"other": "value"}]` + args = map[string]interface{}{"a2ui_json": invalidJSON} + result, _ = tool.Run(context.Background(), args, ctx) + if result["error"] == nil { + t.Error("Expected error for invalid JSON") + } else if !strings.Contains(result["error"].(string), "missing properties: 'text'") { + t.Errorf("Unexpected error message: %v", result["error"]) + } + + // Malformed JSON + malformedJSON := `{"text": "Hello"` // Missing closing brace + args = map[string]interface{}{"a2ui_json": malformedJSON} + result, _ = tool.Run(context.Background(), args, ctx) + if result["error"] == nil { + t.Error("Expected error for malformed JSON") + } + + // Missing Argument + args = map[string]interface{}{} + result, _ = tool.Run(context.Background(), args, ctx) + if result["error"] == nil { + t.Error("Expected error for missing argument") + } + + // Empty string argument + args = map[string]interface{}{"a2ui_json": ""} + result, _ = tool.Run(context.Background(), args, ctx) + if result["error"] == nil { + t.Error("Expected error for empty string argument") + } + + // Non-string argument (Map) - should be marshaled + args = map[string]interface{}{"a2ui_json": map[string]interface{}{"text": "Hello"}} + result, _ = tool.Run(context.Background(), args, ctx) + if result["validated_a2ui_json"] == nil { + t.Error("Expected validated_a2ui_json in result for map argument") + } + list, ok = result["validated_a2ui_json"].([]interface{}) + if !ok || len(list) != 1 { + t.Error("Expected wrapped list for map argument") + } +} + +func TestConverter(t *testing.T) { + // Valid Response + validA2UI := []interface{}{ + map[string]interface{}{"type": "Text", "text": "Hello"}, + } + resp := &FunctionResponse{ + Name: "send_a2ui_json_to_client", + Response: map[string]interface{}{ + "validated_a2ui_json": validA2UI, + }, + } + part := &GenAIPart{FunctionResponse: resp} + + a2aParts := ConvertSendA2UIToClientGenAIPartToA2APart(part) + if len(a2aParts) != 1 { + t.Error("Expected 1 part") + } + if dataPart, _ := GetA2UIDataPart(a2aParts[0]); dataPart == nil { + t.Error("Expected A2UI part") + } + + // Error Response + resp = &FunctionResponse{ + Name: "send_a2ui_json_to_client", + Response: map[string]interface{}{ + "error": "Some error", + }, + } + part = &GenAIPart{FunctionResponse: resp} + a2aParts = ConvertSendA2UIToClientGenAIPartToA2APart(part) + if len(a2aParts) != 0 { + t.Error("Expected 0 parts on error") + } + + // FunctionCall (should be ignored) + call := &FunctionCall{ + Name: "send_a2ui_json_to_client", + Args: map[string]interface{}{"a2ui_json": "[]"}, + } + part = &GenAIPart{FunctionCall: call} + a2aParts = ConvertSendA2UIToClientGenAIPartToA2APart(part) + if len(a2aParts) != 0 { + t.Error("Expected 0 parts for function call") + } + + // Other part type (should convert to TextPart) + part = &GenAIPart{Text: "Some text"} + a2aParts = ConvertSendA2UIToClientGenAIPartToA2APart(part) + if len(a2aParts) != 1 { + t.Error("Expected 1 part for text part") + } + if _, ok := a2aParts[0].(*a2a.TextPart); !ok { + t.Error("Expected TextPart") + } +} diff --git a/a2a_agents/go/a2ui/schema.go b/a2a_agents/go/a2ui/schema.go new file mode 100644 index 000000000..5cd86d33f --- /dev/null +++ b/a2a_agents/go/a2ui/schema.go @@ -0,0 +1,29 @@ +package a2ui + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import "fmt" + +// WrapAsJSONArray wraps the A2UI schema in an array object to support multiple parts. +func WrapAsJSONArray(a2uiSchema map[string]interface{}) (map[string]interface{}, error) { + if len(a2uiSchema) == 0 { + return nil, fmt.Errorf("A2UI schema is empty") + } + + return map[string]interface{}{ + "type": "array", + "items": a2uiSchema, + }, nil +} diff --git a/a2a_agents/go/a2ui/toolset.go b/a2a_agents/go/a2ui/toolset.go new file mode 100644 index 000000000..d4e53d353 --- /dev/null +++ b/a2a_agents/go/a2ui/toolset.go @@ -0,0 +1,375 @@ +package a2ui + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/a2aproject/a2a-go/a2a" + "github.com/santhosh-tekuri/jsonschema/v5" +) + +// --- Simulated GenAI and ADK types --- + +// FunctionDeclaration represents a function declaration for the LLM. +type FunctionDeclaration struct { + Name string + Description string + Parameters map[string]interface{} +} + +// FunctionCall represents a function call from the LLM. +type FunctionCall struct { + Name string + Args map[string]interface{} +} + +// FunctionResponse represents a function response to the LLM. +type FunctionResponse struct { + Name string + Response map[string]interface{} +} + +// GenAIPart represents a part from the Generative AI model. +type GenAIPart struct { + FunctionCall *FunctionCall + FunctionResponse *FunctionResponse + Text string +} + +// ToolContext represents the context for a tool execution. +type ToolContext struct { + Actions ToolActions +} + +// ToolActions represents actions available in the tool context. +type ToolActions struct { + SkipSummarization bool +} + +// LlmRequest represents a request to the LLM. +type LlmRequest struct { + Instructions []string +} + +// AppendInstructions appends instructions to the LLM request. +func (r *LlmRequest) AppendInstructions(instructions []string) { + r.Instructions = append(r.Instructions, instructions...) +} + +// --- End Simulated types --- + +// A2UIEnabledProvider is a function that returns whether A2UI is enabled. +type A2UIEnabledProvider func(ctx context.Context) (bool, error) + +// A2UISchemaProvider is a function that returns the A2UI schema. +type A2UISchemaProvider func(ctx context.Context) (map[string]interface{}, error) + +// BaseTool represents a base tool. +type BaseTool interface { + Name() string + Description() string + GetDeclaration() *FunctionDeclaration + ProcessLLMRequest(ctx context.Context, toolContext *ToolContext, llmRequest *LlmRequest) error + Run(ctx context.Context, args map[string]interface{}, toolContext *ToolContext) (map[string]interface{}, error) +} + +// SendA2UIToClientToolset provides A2UI Tools. +type SendA2UIToClientToolset struct { + a2uiEnabled interface{} // bool or A2UIEnabledProvider + a2uiSchema interface{} // map[string]interface{} or A2UISchemaProvider + sendToolInstance *SendA2UIJsonToClientTool +} + +// NewSendA2UIToClientToolset creates a new SendA2UIToClientToolset. +func NewSendA2UIToClientToolset(enabled interface{}, schema interface{}) *SendA2UIToClientToolset { + return &SendA2UIToClientToolset{ + a2uiEnabled: enabled, + a2uiSchema: schema, + sendToolInstance: NewSendA2UIJsonToClientTool(schema), + } +} + +// resolveA2UIEnabled resolves the enabled state. +func (t *SendA2UIToClientToolset) resolveA2UIEnabled(ctx context.Context) (bool, error) { + if enabled, ok := t.a2uiEnabled.(bool); ok { + return enabled, nil + } + if provider, ok := t.a2uiEnabled.(A2UIEnabledProvider); ok { + return provider(ctx) + } + return false, fmt.Errorf("invalid type for a2uiEnabled") +} + +// GetTools returns the list of tools. +func (t *SendA2UIToClientToolset) GetTools(ctx context.Context) ([]BaseTool, error) { + enabled, err := t.resolveA2UIEnabled(ctx) + if err != nil { + return nil, err + } + if enabled { + log.Println("A2UI is ENABLED, adding ui tools") + return []BaseTool{t.sendToolInstance}, nil + } + log.Println("A2UI is DISABLED, not adding ui tools") + return []BaseTool{}, nil +} + +// SendA2UIJsonToClientTool is the tool for sending A2UI JSON. +type SendA2UIJsonToClientTool struct { + toolName string + description string + a2uiSchema interface{} + a2uiJSONArg string + validatedKey string + toolErrorKey string +} + +// NewSendA2UIJsonToClientTool creates a new tool instance. +func NewSendA2UIJsonToClientTool(schema interface{}) *SendA2UIJsonToClientTool { + toolName := "send_a2ui_json_to_client" + argName := "a2ui_json" + return &SendA2UIJsonToClientTool{ + toolName: toolName, + a2uiJSONArg: argName, + description: fmt.Sprintf("Sends A2UI JSON to the client to render rich UI for the user. This tool can be called multiple times in the same call to render multiple UI surfaces.Args: %s: Valid A2UI JSON Schema to send to the client. The A2UI JSON Schema definition is between ---BEGIN A2UI JSON SCHEMA--- and ---END A2UI JSON SCHEMA--- in the system instructions.", argName), + a2uiSchema: schema, + validatedKey: "validated_a2ui_json", + toolErrorKey: "error", + } +} + +func (t *SendA2UIJsonToClientTool) Name() string { + return t.toolName +} + +func (t *SendA2UIJsonToClientTool) Description() string { + return t.description +} + +func (t *SendA2UIJsonToClientTool) GetDeclaration() *FunctionDeclaration { + return &FunctionDeclaration{ + Name: t.toolName, + Description: t.description, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + t.a2uiJSONArg: map[string]interface{}{ + "type": "string", + "description": "valid A2UI JSON Schema to send to the client.", + }, + }, + "required": []string{t.a2uiJSONArg}, + }, + } +} + +func (t *SendA2UIJsonToClientTool) resolveA2UISchema(ctx context.Context) (map[string]interface{}, error) { + if schema, ok := t.a2uiSchema.(map[string]interface{}); ok { + return schema, nil + } + if provider, ok := t.a2uiSchema.(A2UISchemaProvider); ok { + return provider(ctx) + } + return nil, fmt.Errorf("invalid type for a2uiSchema") +} + +func (t *SendA2UIJsonToClientTool) getA2UISchema(ctx context.Context) (map[string]interface{}, error) { + schema, err := t.resolveA2UISchema(ctx) + if err != nil { + return nil, err + } + return WrapAsJSONArray(schema) +} + +func (t *SendA2UIJsonToClientTool) ProcessLLMRequest(ctx context.Context, toolContext *ToolContext, llmRequest *LlmRequest) error { + schema, err := t.getA2UISchema(ctx) + if err != nil { + return err + } + + schemaBytes, err := json.Marshal(schema) + if err != nil { + return err + } + + instruction := fmt.Sprintf(` +---BEGIN A2UI JSON SCHEMA--- +%s +---END A2UI JSON SCHEMA--- +`, string(schemaBytes)) + llmRequest.AppendInstructions([]string{instruction}) + log.Println("Added a2ui_schema to system instructions") + return nil +} + +func (t *SendA2UIJsonToClientTool) Run(ctx context.Context, args map[string]interface{}, toolContext *ToolContext) (map[string]interface{}, error) { + defer func() { + if r := recover(); r != nil { + log.Printf("Recovered in Run: %v", r) + } + }() + + var a2uiJSONStr string + argVal, ok := args[t.a2uiJSONArg] + if !ok || argVal == nil { + errStr := fmt.Sprintf("Failed to call tool %s because missing required arg %s", t.toolName, t.a2uiJSONArg) + log.Println(errStr) + return map[string]interface{}{t.toolErrorKey: errStr}, nil + } + + switch v := argVal.(type) { + case string: + a2uiJSONStr = v + default: + // If it's not a string (e.g. map or slice), marshal it back to JSON string + log.Printf("Received non-string argument for %s (type %T), marshaling to JSON", t.a2uiJSONArg, v) + bytes, err := json.Marshal(v) + if err != nil { + errStr := fmt.Sprintf("Failed to marshal argument %s: %v", t.a2uiJSONArg, err) + log.Println(errStr) + return map[string]interface{}{t.toolErrorKey: errStr}, nil + } + a2uiJSONStr = string(bytes) + } + + if a2uiJSONStr == "" { + errStr := fmt.Sprintf("Failed to call tool %s because arg %s is empty", t.toolName, t.a2uiJSONArg) + log.Println(errStr) + return map[string]interface{}{t.toolErrorKey: errStr}, nil + } + + var a2uiJSONPayload interface{} + if err := json.Unmarshal([]byte(a2uiJSONStr), &a2uiJSONPayload); err != nil { + errStr := fmt.Sprintf("Failed to call A2UI tool %s: %v", t.toolName, err) + log.Println(errStr) + return map[string]interface{}{t.toolErrorKey: errStr}, nil + } + + // Auto-wrap single object in list + var payloadList []interface{} + if list, ok := a2uiJSONPayload.([]interface{}); ok { + payloadList = list + } else { + log.Println("Received a single JSON object, wrapping in a list for validation.") + payloadList = []interface{}{a2uiJSONPayload} + } + + // Get Schema + schemaMap, err := t.getA2UISchema(ctx) + if err != nil { + errStr := fmt.Sprintf("Failed to resolve schema: %v", err) + log.Println(errStr) + return map[string]interface{}{t.toolErrorKey: errStr}, nil + } + + schemaBytes, err := json.Marshal(schemaMap) + if err != nil { + errStr := fmt.Sprintf("Failed to marshal schema: %v", err) + log.Println(errStr) + return map[string]interface{}{t.toolErrorKey: errStr}, nil + } + + // Compile Schema + c := jsonschema.NewCompiler() + if err := c.AddResource("schema.json", strings.NewReader(string(schemaBytes))); err != nil { + errStr := fmt.Sprintf("Failed to add resource to compiler: %v", err) + log.Println(errStr) + return map[string]interface{}{t.toolErrorKey: errStr}, nil + } + schema, err := c.Compile("schema.json") + if err != nil { + errStr := fmt.Sprintf("Failed to compile schema: %v", err) + log.Println(errStr) + return map[string]interface{}{t.toolErrorKey: errStr}, nil + } + + // Validate + if err := schema.Validate(payloadList); err != nil { + errStr := fmt.Sprintf("Failed to call A2UI tool %s: %v", t.toolName, err) + log.Println(errStr) + return map[string]interface{}{t.toolErrorKey: errStr}, nil + } + + log.Printf("Validated call to tool %s with %s", t.toolName, t.a2uiJSONArg) + + if toolContext != nil { + toolContext.Actions.SkipSummarization = true + } + + return map[string]interface{}{t.validatedKey: payloadList}, nil +} + +// ConvertGenAIPartToA2APart converts a GenAI part to an A2A part. +// +// This function corresponds to `google.adk.a2a.converters.part_converter.convert_genai_part_to_a2a_part` +// in the Python ADK. It is implemented here because an equivalent Go ADK with this +// functionality is currently unavailable in this environment. +// +// It currently supports converting Text parts. Future expansions should handle +// FunctionCalls and other GenAI part types as needed. +func ConvertGenAIPartToA2APart(part *GenAIPart) a2a.Part { + if part.Text != "" { + return &a2a.TextPart{Text: part.Text} + } + // TODO: Handle other part types if necessary (e.g. inline data, function calls) + return nil +} + +// ConvertSendA2UIToClientGenAIPartToA2APart converts a GenAI part to A2A parts. +func ConvertSendA2UIToClientGenAIPartToA2APart(part *GenAIPart) []a2a.Part { + toolName := "send_a2ui_json_to_client" + validatedKey := "validated_a2ui_json" + toolErrorKey := "error" + + if part.FunctionResponse != nil && part.FunctionResponse.Name == toolName { + response := part.FunctionResponse.Response + if _, ok := response[toolErrorKey]; ok { + log.Printf("A2UI tool call failed: %v", response[toolErrorKey]) + return []a2a.Part{} + } + + jsonData, ok := response[validatedKey].([]interface{}) + if !ok || jsonData == nil { + log.Println("No result in A2UI tool response") + return []a2a.Part{} + } + + var finalParts []a2a.Part + log.Printf("Found %d messages. Creating individual DataParts.", len(jsonData)) + for _, message := range jsonData { + if msgMap, ok := message.(map[string]interface{}); ok { + finalParts = append(finalParts, CreateA2UIPart(msgMap)) + } + } + return finalParts + } else if part.FunctionCall != nil && part.FunctionCall.Name == toolName { + // Don't send a2ui tool call to client + return []a2a.Part{} + } + + // Use default converter for other types + convertedPart := ConvertGenAIPartToA2APart(part) + if convertedPart != nil { + log.Printf("Returning converted part: %v", convertedPart) + return []a2a.Part{convertedPart} + } + + return []a2a.Part{} +} diff --git a/a2a_agents/go/go.mod b/a2a_agents/go/go.mod new file mode 100644 index 000000000..f187ba9b6 --- /dev/null +++ b/a2a_agents/go/go.mod @@ -0,0 +1,13 @@ +module github.com/google/A2UI/a2a_agents/go + +go 1.24.8 + +require ( + github.com/a2aproject/a2a-go v0.3.5 + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + golang.org/x/sync v0.15.0 // indirect +) diff --git a/a2a_agents/go/go.sum b/a2a_agents/go/go.sum new file mode 100644 index 000000000..8d86dd50f --- /dev/null +++ b/a2a_agents/go/go.sum @@ -0,0 +1,24 @@ +github.com/a2aproject/a2a-go v0.3.5 h1:sc6p5sVrahAtO0De14iIKt2RCbIu7qp0ZUdaxNufopw= +github.com/a2aproject/a2a-go v0.3.5/go.mod h1:8C0O6lsfR7zWFEqVZz/+zWCoxe8gSWpknEpqm/Vgj3E= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 h1:iOye66xuaAK0WnkPuhQPUFy8eJcmwUXqGGP3om6IxX8= +google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79/go.mod h1:HKJDgKsFUnv5VAGeQjz8kxcgDP0HoE0iZNp0OdZNlhE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 h1:1ZwqphdOdWYXsUHgMpU/101nCtf/kSp9hOrcvFsnl10= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= diff --git a/samples/agent/adk/rizzcharts-go/.env.example b/samples/agent/adk/rizzcharts-go/.env.example new file mode 100644 index 000000000..c4522b4ec --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/.env.example @@ -0,0 +1,7 @@ +# Copy this file to .env and fill in your API key +# Get your API key at: https://aistudio.google.com/apikey + +GEMINI_API_KEY=your_gemini_api_key_here + +# Optional: Use Vertex AI instead of Gemini API +# GOOGLE_GENAI_USE_VERTEXAI=TRUE diff --git a/samples/agent/adk/rizzcharts-go/README.md b/samples/agent/adk/rizzcharts-go/README.md new file mode 100644 index 000000000..5c9b96b5d --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/README.md @@ -0,0 +1,140 @@ +# A2UI Rizzcharts Agent Sample (Go) + +This sample demonstrates how to build an ecommerce dashboard agent using the * +*Go A2UI SDK**. The agent interacts with users to visualize sales data through +interactive charts and maps, leveraging the Agent-to-UI (A2UI) protocol to +stream native UI components to the client. + +## Overview + +The Rizzcharts agent simulates a sales analytics assistant. It can: + +1. **Retrieve Data**: Fetch sales statistics and store locations using defined + tools. +2. **Generate UI**: Construct A2UI JSON payloads to render rich visualizations + (Charts, Maps) on the client. +3. **Adapt**: dynamically select between a "Standard" component catalog and a + custom "Rizzcharts" catalog based on the client's capabilities. + +## Prerequisites + +* **Go**: Version 1.24 or higher. +* **Gemini API Key**: You need an API key + from [Google AI Studio](https://aistudio.google.com/apikey). + +## Setup + +1. **Navigate to the directory**: + ```bash + cd samples/agent/adk/rizzcharts-go + ``` + +2. **Configure Environment**: + Copy the example environment file and add your API key: + ```bash + cp .env.example .env + ``` + Edit `.env` and paste your `GEMINI_API_KEY`. + +3. **Install Dependencies**: + ```bash + go mod tidy + ``` + +## Build and Run + +To run the agent directly: + +```bash +go run . +``` + +To build a binary and run it: + +```bash +go build -o rizzcharts-agent +./rizzcharts-agent +``` + +The server listens on `localhost:10002` by default. You can override this with +flags: + +```bash +./rizzcharts-agent --host 0.0.0.0 --port 8080 +``` + +## How It Works + +This sample illustrates the complete lifecycle of an A2UI-enabled agent in Go. + +### 1. Initialization (`main.go`) + +* The application starts by loading the **A2UI Schema** ( + `server_to_client.json`) and **Component Catalogs** ( + `standard_catalog_definition.json`, `rizzcharts_catalog_definition.json`) from + the specification files. +* It initializes the `RizzchartsAgentExecutor`, which orchestrates the + interaction between the A2A protocol, the Gemini model, and the A2UI tools. +* An HTTP server is started to handle A2A JSON-RPC requests. + +### 2. Session Preparation (`executor.go`) + +* When a request arrives, `PrepareSession` checks if the client supports A2UI by + inspecting the `X-A2A-Extensions` header (handled via + `a2ui.TryActivateA2UIExtension`). +* It analyzes the **Client Capabilities** to determine which Component Catalog + to use (Standard vs. Custom). +* It uses `catalog.go` to **merge** the selected component definitions into the + base A2UI schema. This ensures the LLM generates valid JSON for the specific + set of components available on the client. + +### 3. Instruction Generation (`agent.go`) + +* The `RizzchartsAgent` constructs the system instructions for Gemini. +* It dynamically loads **Example Templates** (`chart.json`, `map.json`) + corresponding to the active catalog. +* These templates are embedded into the system prompt, teaching Gemini how to + construct valid A2UI payloads for specific intents (e.g., "Show Sales" -> + Chart Template, "Show Locations" -> Map Template). + +### 4. Execution Loop (`executor.go`) + +* The executor manages the chat with Gemini. +* It registers the A2UI tool (`send_a2ui_json_to_client`) and the data retrieval + tools (`get_sales_data`, `get_store_sales`) with the model. +* **Tool Execution**: + 1. Gemini calls a data tool (e.g., `get_sales_data`). + 2. The tool returns raw data. + 3. Gemini uses the data and the embedded templates to construct an A2UI JSON + payload. + 4. Gemini calls `send_a2ui_json_to_client` with this payload. +* **Validation**: The `send_a2ui_json_to_client` tool (from the SDK) validates + the generated JSON against the active schema to ensure correctness before + sending. + +### 5. Response Construction + +* The executor captures the validated A2UI payload. +* It wraps the payload in an A2A `DataPart` with the MIME type + `application/json+a2ui`. +* The final response, containing both the text reply and the UI payload, is sent + back to the client. + +## Project Structure + +* **`main.go`**: Entry point; server setup; loads resources. +* **`agent.go`**: Agent definition; prompt engineering; loads JSON examples. +* **`executor.go`**: A2A/Gemini integration logic; session state management. +* **`catalog.go`**: Logic for selecting and merging A2UI schemas and catalogs. +* **`tools.go`**: Mock data tools (`get_store_sales`, `get_sales_data`). +* **`examples/`**: Contains the "Golden JSON" templates for the LLM to mimic. + * `standard_catalog/`: Templates using standard A2UI components. + * `rizzcharts_catalog/`: Templates using custom components defined in + `rizzcharts_catalog_definition.json`. + +## Disclaimer + +**Important**: This code is for demonstration purposes. In a production +environment, ensure you treat all incoming agent data as untrusted. Implement +strict validation and security measures when rendering UI or processing tool +inputs. diff --git a/samples/agent/adk/rizzcharts-go/agent.go b/samples/agent/adk/rizzcharts-go/agent.go new file mode 100644 index 000000000..12bdbd7f6 --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/agent.go @@ -0,0 +1,232 @@ +package main + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/google/A2UI/a2a_agents/go/a2ui" + "github.com/santhosh-tekuri/jsonschema/v5" +) + +const ( + RizzchartsCatalogURI = "https://github.com/google/A2UI/blob/main/samples/agent/adk/rizzcharts/rizzcharts_catalog_definition.json" + A2UICatalogURIStateKey = "user:a2ui_catalog_uri" +) + +// RizzchartsAgent represents the ecommerce dashboard agent. +type RizzchartsAgent struct { + Name string + Description string + Tools []a2ui.BaseTool + a2uiEnabledProvider a2ui.A2UIEnabledProvider + a2uiSchemaProvider a2ui.A2UISchemaProvider +} + +// NewRizzchartsAgent creates a new RizzchartsAgent. +func NewRizzchartsAgent(enabledProvider a2ui.A2UIEnabledProvider, schemaProvider a2ui.A2UISchemaProvider) *RizzchartsAgent { + toolset := a2ui.NewSendA2UIToClientToolset(enabledProvider, schemaProvider) + // In a real app, we'd pass a context. Using background for init. + tools, _ := toolset.GetTools(context.Background()) + + allTools := []a2ui.BaseTool{ + &GetStoreSalesTool{}, + &GetSalesDataTool{}, + } + allTools = append(allTools, tools...) + + return &RizzchartsAgent{ + Name: "rizzcharts_agent", + Description: "An agent that lets sales managers request sales data.", + Tools: allTools, + a2uiEnabledProvider: enabledProvider, + a2uiSchemaProvider: schemaProvider, + } +} + +// GetA2UISchema retrieves and wraps the A2UI schema. +func (a *RizzchartsAgent) GetA2UISchema(ctx context.Context) (map[string]interface{}, error) { + schema, err := a.a2uiSchemaProvider(ctx) + if err != nil { + return nil, err + } + return a2ui.WrapAsJSONArray(schema) +} + +// LoadExample loads and returns the example as interface{} +func (a *RizzchartsAgent) LoadExample(ctx context.Context, path string, a2uiSchema map[string]interface{}) (interface{}, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read example file %s: %w", path, err) + } + + var exampleJSON interface{} + if err := json.Unmarshal(data, &exampleJSON); err != nil { + return nil, fmt.Errorf("failed to parse example JSON: %w", err) + } + + schemaBytes, err := json.Marshal(a2uiSchema) + if err != nil { + return nil, err + } + + c := jsonschema.NewCompiler() + if err := c.AddResource("schema.json", strings.NewReader(string(schemaBytes))); err != nil { + return nil, err + } + schema, err := c.Compile("schema.json") + if err != nil { + return nil, err + } + + if err := schema.Validate(exampleJSON); err != nil { + return nil, fmt.Errorf("example validation failed: %w", err) + } + + return exampleJSON, nil +} + +// GetInstructions generates the system instructions for the agent. +func (a *RizzchartsAgent) GetInstructions(ctx context.Context, state map[string]interface{}) (string, error) { + // Check enabled state from state map if present, or fallback to provider + // In Python: use_ui = self._a2ui_enabled_provider(readonly_context) + // where the provider reads from ctx.state + + // Since we updated PrepareSession to populate state[a2uiEnabledKey], we can read it directly + // or assume the provider passed to constructor does it (which is mocked in main.go). + // To be consistent with the dynamic update, we should check the state. + + useUI := false + if val, ok := state[a2uiEnabledKey].(bool); ok { + useUI = val + } + + if !useUI { + return "", fmt.Errorf("A2UI must be enabled to run rizzcharts agent") + } + + // Retrieve schema from state + var a2uiSchema map[string]interface{} + if val, ok := state[a2uiSchemaKey].(map[string]interface{}); ok { + a2uiSchema, _ = a2ui.WrapAsJSONArray(val) + } else { + return "", fmt.Errorf("A2UI schema not found in state") + } + + catalogURI, _ := state[A2UICatalogURIStateKey].(string) + + var mapExample, chartExample interface{} + + // Determine paths based on catalog URI + // Note: Paths are relative to the working directory when running the executable + var baseExampleDir string + if catalogURI == RizzchartsCatalogURI { + baseExampleDir = "examples/rizzcharts_catalog" + } else if catalogURI == a2ui.StandardCatalogID { + baseExampleDir = "examples/standard_catalog" + } else { + return "", fmt.Errorf("unsupported catalog uri: %s", catalogURI) + } + + var err error + mapExample, err = a.LoadExample(ctx, filepath.Join(baseExampleDir, "map.json"), a2uiSchema) + if err != nil { + return "", err + } + chartExample, err = a.LoadExample(ctx, filepath.Join(baseExampleDir, "chart.json"), a2uiSchema) + if err != nil { + return "", err + } + + mapExampleBytes, _ := json.Marshal(mapExample) + chartExampleBytes, _ := json.Marshal(chartExample) + + finalPrompt := ` +### System Instructions + +You are an expert A2UI Ecommerce Dashboard analyst. Your primary function is to translate user requests for ecommerce data into A2UI JSON payloads to display charts and visualizations. You MUST use the ` + "`send_a2ui_json_to_client`" + ` tool with the ` + "`a2ui_json`" + ` argument set to the A2UI JSON payload to send to the client. You should also include a brief text message with each response saying what you did and asking if you can help with anything else. + +**Core Objective:** To provide a dynamic and interactive dashboard by constructing UI surfaces with the appropriate visualization components based on user queries. + +**Key Components & Examples:** + +You will be provided a schema that defines the A2UI message structure and two key generic component templates for displaying data. + +1. **Charts:** Used for requests about sales breakdowns, revenue performance, comparisons, or trends. + * **Template:** Use the JSON from ` + "`---BEGIN CHART EXAMPLE---`" + `. +2. **Maps:** Used for requests about regional data, store locations, geography-based performance, or regional outliers. + * **Template:** Use the JSON from ` + "`---BEGIN MAP EXAMPLE---`" + `. + +You will also use layout components like ` + "`Column`" + ` (as the ` + "`root`" + `) and ` + "`Text`" + ` (to provide a title). + +--- + +### Workflow and Rules + +Your task is to analyze the user's request, fetch the necessary data, select the correct generic template, and send the corresponding A2UI JSON payload. + +1. **Analyze the Request:** Determine the user's intent (Visual Chart vs. Geospatial Map). + * "show my sales breakdown by product category for q3" -> **Intent:** Chart. + * "show revenue trends yoy by month" -> **Intent:** Chart. + * "were there any outlier stores in the northeast region" -> **Intent:** Map. + +2. **Fetch Data:** Select and use the appropriate tool to retrieve the necessary data. + * Use **` + "`get_sales_data`" + `** for general sales, revenue, and product category trends (typically for Charts). + * Use **` + "`get_store_sales`" + `** for regional performance, store locations, and geospatial outliers (typically for Maps). + +3. **Select Example:** Based on the intent, choose the correct example block to use as your template. + * **Intent** (Chart/Data Viz) -> Use ` + "`---BEGIN CHART EXAMPLE---`" + `. + * **Intent** (Map/Geospatial) -> Use ` + "`---BEGIN MAP EXAMPLE---`" + `. + +4. **Construct the JSON Payload:** + * Use the **entire** JSON array from the chosen example as the base value for the ` + "`a2ui_json`" + ` argument. + * **Generate a new ` + "`surfaceId`" + `:** You MUST generate a new, unique ` + "`surfaceId`" + ` for this request (e.g., ` + "`sales_breakdown_q3_surface`" + `, ` + "`regional_outliers_northeast_surface`" + `). This new ID must be used for the ` + "`surfaceId`" + ` in all three messages within the JSON array (` + "`beginRendering`" + `, ` + "`surfaceUpdate`" + `, ` + "`dataModelUpdate`" + `). + * **Update the title Text:** You MUST update the ` + "`literalString`" + ` value for the ` + "`Text`" + ` component (the component with ` + "`id: \"page_header\"`" + `) to accurately reflect the specific user query. For example, if the user asks for "Q3" sales, update the generic template text to "Q3 2025 Sales by Product Category". + * Ensure the generated JSON perfectly matches the A2UI specification. It will be validated against the json_schema and rejected if it does not conform. + * If you get an error in the tool response apologize to the user and let them know they should try again. + +5. **Call the Tool:** Call the ` + "`send_a2ui_json_to_client`" + ` tool with the fully constructed ` + "`a2ui_json`" + ` payload. The ` + "`a2ui_json`" + ` argument MUST be a string containing the JSON structure. + * **NEVER provide the map description in text.** + * **IMMEDIATELY call the ` + "`send_a2ui_json_to_client`" + ` tool after receiving data from a data tool.** + +**Thought Process:** +Always think step-by-step before answering. +1. Identify the user's intent. +2. Identify the necessary data and tool to fetch it. +3. Call the data tool. +4. **IMMEDIATELY** select the A2UI template based on data and intent. +5. Construct the A2UI JSON. +6. Call ` + "`send_a2ui_json_to_client`" + `. + +---BEGIN CHART EXAMPLE--- +%s +---END CHART EXAMPLE--- + +---BEGIN MAP EXAMPLE--- +%s +---END MAP EXAMPLE--- +` + + finalPrompt = fmt.Sprintf(finalPrompt, string(chartExampleBytes), string(mapExampleBytes)) + + log.Printf("Generated system instructions for A2UI ENABLED and catalog %s", catalogURI) + return finalPrompt, nil +} diff --git a/samples/agent/adk/rizzcharts-go/catalog.go b/samples/agent/adk/rizzcharts-go/catalog.go new file mode 100644 index 000000000..4aacdbf83 --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/catalog.go @@ -0,0 +1,157 @@ +package main + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/google/A2UI/a2a_agents/go/a2ui" +) + +// ComponentCatalogBuilder handles loading and merging component catalogs. +type ComponentCatalogBuilder struct { + a2uiSchemaContent string + uriToLocalCatalogContent map[string]string + defaultCatalogURI string +} + +// NewComponentCatalogBuilder creates a new ComponentCatalogBuilder. +func NewComponentCatalogBuilder(schemaContent string, uriToLocalContent map[string]string, defaultURI string) *ComponentCatalogBuilder { + return &ComponentCatalogBuilder{ + a2uiSchemaContent: schemaContent, + uriToLocalCatalogContent: uriToLocalContent, + defaultCatalogURI: defaultURI, + } +} + +// LoadA2UISchema loads the schema and catalog based on client capabilities. +func (b *ComponentCatalogBuilder) LoadA2UISchema(clientUICapabilities map[string]interface{}) (map[string]interface{}, string, error) { + log.Printf("Loading A2UI client capabilities %v", clientUICapabilities) + + var catalogURI string + var inlineCatalogStr string + + if clientUICapabilities != nil { + supportedIDsRaw, _ := clientUICapabilities[a2ui.SupportedCatalogIDsKey].([]interface{}) + var supportedIDs []string + for _, id := range supportedIDsRaw { + if strID, ok := id.(string); ok { + supportedIDs = append(supportedIDs, strID) + } + } + + // Check supported catalogs + found := false + for _, uri := range []string{RizzchartsCatalogURI, a2ui.StandardCatalogID} { + for _, supported := range supportedIDs { + if supported == uri { + catalogURI = uri + found = true + break + } + } + if found { + break + } + } + + inlineCatalogStr, _ = clientUICapabilities[a2ui.InlineCatalogsKey].(string) + } else if b.defaultCatalogURI != "" { + log.Printf("Using default catalog %s since client UI capabilities not found", b.defaultCatalogURI) + catalogURI = b.defaultCatalogURI + } else { + return nil, "", fmt.Errorf("client UI capabilities not provided") + } + + var catalogJSON map[string]interface{} + + if catalogURI != "" && inlineCatalogStr != "" { + return nil, "", fmt.Errorf("cannot set both supportedCatalogIds and inlineCatalogs") + } else if catalogURI != "" { + if content, ok := b.uriToLocalCatalogContent[catalogURI]; ok { + log.Printf("Loading local component catalog with uri %s", catalogURI) + if err := json.Unmarshal([]byte(content), &catalogJSON); err != nil { + return nil, "", fmt.Errorf("failed to parse local catalog: %w", err) + } + } else { + return nil, "", fmt.Errorf("local component catalog with URI %s not found", catalogURI) + } + } else if inlineCatalogStr != "" { + log.Printf("Loading inline component catalog") + if err := json.Unmarshal([]byte(inlineCatalogStr), &catalogJSON); err != nil { + return nil, "", fmt.Errorf("failed to parse inline catalog: %w", err) + } + } else { + return nil, "", fmt.Errorf("no supported catalogs found") + } + + // Simple $ref resolution for the sample: if the catalog refs the standard catalog, merge them. + if components, ok := catalogJSON["components"].(map[string]interface{}); ok { + if ref, ok := components["$ref"].(string); ok { + // Heuristic: if it looks like the standard catalog ref, merge standard components. + if strings.Contains(ref, "standard_catalog_definition.json") { + if standardContent, ok := b.uriToLocalCatalogContent[a2ui.StandardCatalogID]; ok { + var standardJSON map[string]interface{} + if err := json.Unmarshal([]byte(standardContent), &standardJSON); err == nil { + if standardComps, ok := standardJSON["components"].(map[string]interface{}); ok { + log.Println("Merging standard components into custom catalog") + for k, v := range standardComps { + if _, exists := components[k]; !exists { + components[k] = v + } + } + delete(components, "$ref") + } + } + } + } + } + } + + log.Println("Loading A2UI schema") + var a2uiSchemaJSON map[string]interface{} + if err := json.Unmarshal([]byte(b.a2uiSchemaContent), &a2uiSchemaJSON); err != nil { + return nil, "", fmt.Errorf("failed to parse A2UI schema: %w", err) + } + + // Merge catalog into schema + // Path: properties -> surfaceUpdate -> properties -> components -> items -> properties -> component -> properties + if props, ok := a2uiSchemaJSON["properties"].(map[string]interface{}); ok { + if su, ok := props["surfaceUpdate"].(map[string]interface{}); ok { + if suProps, ok := su["properties"].(map[string]interface{}); ok { + if comps, ok := suProps["components"].(map[string]interface{}); ok { + if items, ok := comps["items"].(map[string]interface{}); ok { + if itemsProps, ok := items["properties"].(map[string]interface{}); ok { + if comp, ok := itemsProps["component"].(map[string]interface{}); ok { + // Correctly drill down to "components" in the catalog definition if it exists. + // This matches how catalogs are structured (e.g., standard_catalog_definition.json has a top-level "components" key). + if components, ok := catalogJSON["components"].(map[string]interface{}); ok { + comp["properties"] = components + } else { + comp["properties"] = catalogJSON + } + } + } + } + } + } + } + } + + return a2uiSchemaJSON, catalogURI, nil +} diff --git a/samples/agent/adk/rizzcharts-go/examples/rizzcharts_catalog/chart.json b/samples/agent/adk/rizzcharts-go/examples/rizzcharts_catalog/chart.json new file mode 100644 index 000000000..ccaee8f3a --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/examples/rizzcharts_catalog/chart.json @@ -0,0 +1,105 @@ +[ + { + "beginRendering": { + "surfaceId": "sales-dashboard", + "root": "root-canvas" + } + }, + { + "surfaceUpdate": { + "surfaceId": "sales-dashboard", + "components": [ + { + "id": "root-canvas", + "component": { + "Canvas": { + "children": { + "explicitList": [ + "chart-container" + ] + } + } + } + }, + { + "id": "chart-container", + "component": { + "Column": { + "children": { + "explicitList": [ + "sales-chart" + ] + }, + "alignment": "center" + } + } + }, + { + "id": "sales-chart", + "component": { + "Chart": { + "type": "doughnut", + "title": { + "path": "chart.title" + }, + "chartData": { + "path": "chart.items" + } + } + } + } + ] + } + }, + { + "dataModelUpdate": { + "surfaceId": "sales-dashboard", + "path": "/", + "contents": [ + {"key": "chart.title", "valueString": "Sales by Category"}, + {"key": "chart.items[0].label", "valueString": "Apparel"}, + {"key": "chart.items[0].value", "valueNumber": 41}, + {"key": "chart.items[0].drillDown[0].label", "valueString": "Tops"}, + {"key": "chart.items[0].drillDown[0].value", "valueNumber": 31}, + {"key": "chart.items[0].drillDown[1].label", "valueString": "Bottoms"}, + {"key": "chart.items[0].drillDown[1].value", "valueNumber": 38}, + { + "key": "chart.items[0].drillDown[2].label", "valueString": "Outerwear" + }, + {"key": "chart.items[0].drillDown[2].value", "valueNumber": 20}, + {"key": "chart.items[0].drillDown[3].label", "valueString": "Footwear"}, + {"key": "chart.items[0].drillDown[3].value", "valueNumber": 11}, + {"key": "chart.items[1].label", "valueString": "Home Goods"}, + {"key": "chart.items[1].value", "valueNumber": 15}, + {"key": "chart.items[1].drillDown[0].label", "valueString": "Pillow"}, + {"key": "chart.items[1].drillDown[0].value", "valueNumber": 8}, + { + "key": "chart.items[1].drillDown[1].label", + "valueString": "Coffee Maker" + }, + {"key": "chart.items[1].drillDown[1].value", "valueNumber": 16}, + {"key": "chart.items[1].drillDown[2].label", "valueString": "Area Rug"}, + {"key": "chart.items[1].drillDown[2].value", "valueNumber": 3}, + { + "key": "chart.items[1].drillDown[3].label", + "valueString": "Bath Towels" + }, + {"key": "chart.items[1].drillDown[3].value", "valueNumber": 14}, + {"key": "chart.items[2].label", "valueString": "Electronics"}, + {"key": "chart.items[2].value", "valueNumber": 28}, + {"key": "chart.items[2].drillDown[0].label", "valueString": "Phones"}, + {"key": "chart.items[2].drillDown[0].value", "valueNumber": 25}, + {"key": "chart.items[2].drillDown[1].label", "valueString": "Laptops"}, + {"key": "chart.items[2].drillDown[1].value", "valueNumber": 27}, + {"key": "chart.items[2].drillDown[2].label", "valueString": "TVs"}, + {"key": "chart.items[2].drillDown[2].value", "valueNumber": 21}, + {"key": "chart.items[2].drillDown[3].label", "valueString": "Other"}, + {"key": "chart.items[2].drillDown[3].value", "valueNumber": 27}, + {"key": "chart.items[3].label", "valueString": "Health & Beauty"}, + {"key": "chart.items[3].value", "valueNumber": 10}, + {"key": "chart.items[4].label", "valueString": "Other"}, + {"key": "chart.items[4].value", "valueNumber": 6} + ] + } + } +] diff --git a/samples/agent/adk/rizzcharts-go/examples/rizzcharts_catalog/map.json b/samples/agent/adk/rizzcharts-go/examples/rizzcharts_catalog/map.json new file mode 100644 index 000000000..093006c27 --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/examples/rizzcharts_catalog/map.json @@ -0,0 +1,119 @@ +[ + { + "beginRendering": { + "surfaceId": "la-map-view", + "root": "root-canvas" + } + }, + { + "surfaceUpdate": { + "surfaceId": "la-map-view", + "components": [ + { + "id": "root-canvas", + "component": { + "Canvas": { + "children": { + "explicitList": [ + "map-layout-container" + ] + } + } + } + }, + { + "id": "map-layout-container", + "component": { + "Column": { + "children": { + "explicitList": [ + "map-header", + "location-map" + ] + }, + "alignment": "stretch" + } + } + }, + { + "id": "map-header", + "component": { + "Text": { + "text": { + "literalString": "Points of Interest in Los Angeles" + }, + "usageHint": "h2" + } + } + }, + { + "id": "location-map", + "component": { + "GoogleMap": { + "center": { + "path": "mapConfig.center" + }, + "zoom": { + "path": "mapConfig.zoom" + }, + "pins": { + "path": "mapConfig.locations" + } + } + } + } + ] + } + }, + { + "dataModelUpdate": { + "surfaceId": "la-map-view", + "path": "/", + "contents": [ + {"key": "mapConfig.center.lat", "valueNumber": 34.0522}, + {"key": "mapConfig.center.lng", "valueNumber": -118.2437}, + {"key": "mapConfig.zoom", "valueNumber": 11}, + {"key": "mapConfig.locations[0].lat", "valueNumber": 34.0135}, + {"key": "mapConfig.locations[0].lng", "valueNumber": -118.4947}, + { + "key": "mapConfig.locations[0].name", + "valueString": "Google Store Santa Monica" + }, + { + "key": "mapConfig.locations[0].description", + "valueString": "Your local destination for Google hardware." + }, + {"key": "mapConfig.locations[0].background", "valueString": "#4285F4"}, + {"key": "mapConfig.locations[0].borderColor", "valueString": "#FFFFFF"}, + {"key": "mapConfig.locations[0].glyphColor", "valueString": "#FFFFFF"}, + {"key": "mapConfig.locations[1].lat", "valueNumber": 34.1341}, + {"key": "mapConfig.locations[1].lng", "valueNumber": -118.3215}, + { + "key": "mapConfig.locations[1].name", + "valueString": "Griffith Observatory" + }, + {"key": "mapConfig.locations[2].lat", "valueNumber": 34.1340}, + {"key": "mapConfig.locations[2].lng", "valueNumber": -118.3397}, + { + "key": "mapConfig.locations[2].name", + "valueString": "Hollywood Sign Viewpoint" + }, + {"key": "mapConfig.locations[3].lat", "valueNumber": 34.0453}, + {"key": "mapConfig.locations[3].lng", "valueNumber": -118.2673}, + { + "key": "mapConfig.locations[3].name", + "valueString": "Crypto.com Arena" + }, + {"key": "mapConfig.locations[4].lat", "valueNumber": 34.0639}, + {"key": "mapConfig.locations[4].lng", "valueNumber": -118.3592}, + {"key": "mapConfig.locations[4].name", "valueString": "LACMA"}, + {"key": "mapConfig.locations[5].lat", "valueNumber": 33.9850}, + {"key": "mapConfig.locations[5].lng", "valueNumber": -118.4729}, + { + "key": "mapConfig.locations[5].name", + "valueString": "Venice Beach Boardwalk" + } + ] + } + } +] diff --git a/samples/agent/adk/rizzcharts-go/examples/standard_catalog/chart.json b/samples/agent/adk/rizzcharts-go/examples/standard_catalog/chart.json new file mode 100644 index 000000000..d04d19347 --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/examples/standard_catalog/chart.json @@ -0,0 +1,118 @@ +[ + { + "beginRendering": { + "surfaceId": "sales-dashboard", + "root": "root-column", + "styles": { + "primaryColor": "#00BFFF", + "font": "Arial" + } + } + }, + { + "surfaceUpdate": { + "surfaceId": "sales-dashboard", + "components": [ + { + "id": "root-column", + "component": { + "Column": { + "children": { + "explicitList": [ + "chart-title", + "category-list" + ] + } + } + } + }, + { + "id": "chart-title", + "component": { + "Text": { + "text": { + "path": "chart.title" + }, + "usageHint": "h2" + } + } + }, + { + "id": "category-list", + "component": { + "List": { + "direction": "vertical", + "children": { + "template": { + "componentId": "category-item-template", + "dataBinding": "/chart.items" + } + } + } + } + }, + { + "id": "category-item-template", + "component": { + "Card": { + "child": "item-row" + } + } + }, + { + "id": "item-row", + "component": { + "Row": { + "distribution": "spaceBetween", + "children": { + "explicitList": [ + "item-label", + "item-value" + ] + } + } + } + }, + { + "id": "item-label", + "component": { + "Text": { + "text": { + "path": "label" + } + } + } + }, + { + "id": "item-value", + "component": { + "Text": { + "text": { + "path": "value" + } + } + } + } + ] + } + }, + { + "dataModelUpdate": { + "surfaceId": "sales-dashboard", + "path": "/", + "contents": [ + {"key": "chart.title", "valueString": "Sales by Category"}, + {"key": "chart.items[0].label", "valueString": "Apparel"}, + {"key": "chart.items[0].value", "valueNumber": 41}, + {"key": "chart.items[1].label", "valueString": "Home Goods"}, + {"key": "chart.items[1].value", "valueNumber": 15}, + {"key": "chart.items[2].label", "valueString": "Electronics"}, + {"key": "chart.items[2].value", "valueNumber": 28}, + {"key": "chart.items[3].label", "valueString": "Health & Beauty"}, + {"key": "chart.items[3].value", "valueNumber": 10}, + {"key": "chart.items[4].label", "valueString": "Other"}, + {"key": "chart.items[4].value", "valueNumber": 6} + ] + } + } +] diff --git a/samples/agent/adk/rizzcharts-go/examples/standard_catalog/map.json b/samples/agent/adk/rizzcharts-go/examples/standard_catalog/map.json new file mode 100644 index 000000000..3eca41f88 --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/examples/standard_catalog/map.json @@ -0,0 +1,154 @@ +[ + { + "beginRendering": { + "surfaceId": "la-map-view", + "root": "root-column", + "styles": { + "primaryColor": "#4285F4", + "font": "Roboto" + } + } + }, + { + "surfaceUpdate": { + "surfaceId": "la-map-view", + "components": [ + { + "id": "root-column", + "component": { + "Column": { + "children": { + "explicitList": [ + "map-header", + "map-image", + "location-list" + ] + }, + "alignment": "stretch" + } + } + }, + { + "id": "map-header", + "component": { + "Text": { + "text": { + "literalString": "Points of Interest in Los Angeles" + }, + "usageHint": "h2" + } + } + }, + { + "id": "location-list", + "component": { + "List": { + "direction": "vertical", + "children": { + "template": { + "componentId": "location-card-template", + "dataBinding": "/mapConfig.locations" + } + } + } + } + }, + { + "id": "location-card-template", + "component": { + "Card": { + "child": "location-details" + } + } + }, + { + "id": "location-details", + "component": { + "Column": { + "children": { + "explicitList": [ + "location-name", + "location-description" + ] + } + } + } + }, + { + "id": "location-name", + "component": { + "Text": { + "text": { + "path": "name" + }, + "usageHint": "h4" + } + } + }, + { + "id": "location-description", + "component": { + "Text": { + "text": { + "path": "description" + } + } + } + } + ] + } + }, + { + "dataModelUpdate": { + "surfaceId": "la-map-view", + "path": "/", + "contents": [ + { + "key": "mapConfig.locations[0].name", + "valueString": "Google Store Santa Monica" + }, + { + "key": "mapConfig.locations[0].description", + "valueString": "Your local destination for Google hardware." + }, + { + "key": "mapConfig.locations[1].name", + "valueString": "Griffith Observatory" + }, + { + "key": "mapConfig.locations[1].description", + "valueString": "A public observatory with views of the Hollywood Sign." + }, + { + "key": "mapConfig.locations[2].name", + "valueString": "Hollywood Sign Viewpoint" + }, + { + "key": "mapConfig.locations[2].description", + "valueString": "Iconic landmark in the Hollywood Hills." + }, + { + "key": "mapConfig.locations[3].name", + "valueString": "Crypto.com Arena" + }, + { + "key": "mapConfig.locations[3].description", + "valueString": "Multi-purpose sports and entertainment arena." + }, + {"key": "mapConfig.locations[4].name", "valueString": "LACMA"}, + { + "key": "mapConfig.locations[4].description", + "valueString": "Los Angeles County Museum of Art." + }, + { + "key": "mapConfig.locations[5].name", + "valueString": "Venice Beach Boardwalk" + }, + { + "key": "mapConfig.locations[5].description", + "valueString": "Famous oceanfront promenade." + } + ] + } + } +] diff --git a/samples/agent/adk/rizzcharts-go/executor.go b/samples/agent/adk/rizzcharts-go/executor.go new file mode 100644 index 000000000..2a8765189 --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/executor.go @@ -0,0 +1,458 @@ +package main + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/a2asrv/eventqueue" + "github.com/google/A2UI/a2a_agents/go/a2ui" + "github.com/google/generative-ai-go/genai" + "google.golang.org/api/option" +) + +const ( + a2uiEnabledKey = "system:a2ui_enabled" + a2uiSchemaKey = "system:a2ui_schema" +) + +// RizzchartsAgentExecutor handles agent execution and A2A integration. +type RizzchartsAgentExecutor struct { + baseURL string + componentCatalogBuilder *ComponentCatalogBuilder + agent *RizzchartsAgent +} + +// NewRizzchartsAgentExecutor creates a new executor. +func NewRizzchartsAgentExecutor(baseURL string, builder *ComponentCatalogBuilder, agent *RizzchartsAgent) *RizzchartsAgentExecutor { + return &RizzchartsAgentExecutor{ + baseURL: baseURL, + componentCatalogBuilder: builder, + agent: agent, + } +} + +// GetAgentCard returns the AgentCard defining this agent's metadata and skills. +func (e *RizzchartsAgentExecutor) GetAgentCard() *a2a.AgentCard { + supportedContentTypes := []string{"text", "text/plain"} + + // Dereference the pointer returned by GetA2UIAgentExtension + a2uiExt := *a2ui.GetA2UIAgentExtension(false, []string{a2ui.StandardCatalogID, RizzchartsCatalogURI}) + + return &a2a.AgentCard{ + Name: "Ecommerce Dashboard Agent", + Description: "This agent visualizes ecommerce data, showing sales breakdowns, YOY revenue performance, and regional sales outliers.", + URL: e.baseURL, + Version: "1.0.0", + DefaultInputModes: supportedContentTypes, + DefaultOutputModes: supportedContentTypes, + PreferredTransport: "JSONRPC", + ProtocolVersion: "0.3.0", + Capabilities: a2a.AgentCapabilities{ + Streaming: true, + Extensions: []a2a.AgentExtension{ + a2uiExt, + }, + }, + Skills: []a2a.AgentSkill{ + { + ID: "view_sales_by_category", + Name: "View Sales by Category", + Description: "Displays a pie chart of sales broken down by product category for a given time period.", + Tags: []string{"sales", "breakdown", "category", "pie chart", "revenue"}, + Examples: []string{ + "show my sales breakdown by product category for q3", + "What's the sales breakdown for last month?", + }, + }, + { + ID: "view_regional_outliers", + Name: "View Regional Sales Outliers", + Description: "Displays a map showing regional sales outliers or store-level performance.", + Tags: []string{"sales", "regional", "outliers", "stores", "map", "performance"}, + Examples: []string{ + "interesting. were there any outlier stores", + "show me a map of store performance", + }, + }, + }, + } +} + +// PrepareSession handles session preparation logic, including A2UI state setup. +// It matches the logic in the Python sample's _prepare_session method. +func (e *RizzchartsAgentExecutor) PrepareSession(ctx context.Context, state map[string]interface{}, reqCtx *a2asrv.RequestContext) error { + log.Printf("Preparing session") + state["base_url"] = e.baseURL + + // Check if A2UI is enabled for this request using the extension mechanism + useUI := a2ui.TryActivateA2UIExtension(ctx) + + if useUI { + log.Println("A2UI extension activated") + + // Extract client capabilities from the message metadata + var clientCapabilities map[string]interface{} + if reqCtx != nil && reqCtx.Message != nil && reqCtx.Message.Metadata != nil { + if caps, ok := reqCtx.Message.Metadata[a2ui.ClientCapabilitiesKey].(map[string]interface{}); ok { + clientCapabilities = caps + } + } + + a2uiSchema, catalogURI, err := e.componentCatalogBuilder.LoadA2UISchema(clientCapabilities) + if err != nil { + return err + } + + // Update state with A2UI configuration + state[a2uiEnabledKey] = true + state[a2uiSchemaKey] = a2uiSchema + state[A2UICatalogURIStateKey] = catalogURI + } else { + log.Println("A2UI extension NOT activated") + } + + return nil +} + +// Execute implements a2asrv.AgentExecutor. +func (e *RizzchartsAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { + log.Println("Executor: Execute started") + state := make(map[string]interface{}) + + // Task State: Submitted (if new) + if reqCtx.StoredTask == nil { + log.Println("Executor: Sending TaskStateSubmitted") + event := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateSubmitted, nil) + if err := queue.Write(ctx, event); err != nil { + log.Printf("Executor: Failed to write submitted event: %v", err) + return fmt.Errorf("failed to write state submitted: %w", err) + } + } + + // Prepare session (A2UI setup) + if err := e.PrepareSession(ctx, state, reqCtx); err != nil { + log.Printf("Executor: PrepareSession failed: %v", err) + event := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateFailed, &a2a.Message{ + Role: a2a.MessageRoleUnspecified, + Parts: []a2a.Part{ + &a2a.TextPart{Text: fmt.Sprintf("Failed to prepare session: %v", err)}, + }, + }) + queue.Write(ctx, event) + return nil + } + + // Task State: Working + log.Println("Executor: Sending TaskStateWorking") + event := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateWorking, nil) + if err := queue.Write(ctx, event); err != nil { + log.Printf("Executor: Failed to write working event: %v", err) + return fmt.Errorf("failed to write state working: %w", err) + } + + // Extract User Text + var userText string + if reqCtx.Message != nil { + log.Printf("Executor: Inspecting %d parts", len(reqCtx.Message.Parts)) + for i, p := range reqCtx.Message.Parts { + log.Printf("Executor: Part %d type: %T", i, p) + if txtPart, ok := p.(*a2a.TextPart); ok { + userText = txtPart.Text + } + } + } + log.Printf("Executor: User text: %q", userText) + + // --- Gemini Integration --- + + client, err := genai.NewClient(ctx, option.WithAPIKey(os.Getenv("GEMINI_API_KEY"))) + if err != nil { + log.Printf("Failed to create Gemini client: %v", err) + return err + } + defer client.Close() + + model := client.GenerativeModel("gemini-2.5-flash") + model.SetTemperature(0.0) // Deterministic + + // Convert tools + var modelTools []*genai.FunctionDeclaration + for _, t := range e.agent.Tools { + decl := t.GetDeclaration() + + props := &genai.Schema{Type: genai.TypeObject, Properties: make(map[string]*genai.Schema)} + if pMap, ok := decl.Parameters["properties"].(map[string]interface{}); ok { + for name, pDef := range pMap { + if defMap, ok := pDef.(map[string]interface{}); ok { + s := &genai.Schema{} + if typeStr, ok := defMap["type"].(string); ok { + switch typeStr { + case "number", "integer": + s.Type = genai.TypeNumber + case "boolean": + s.Type = genai.TypeBoolean + default: + s.Type = genai.TypeString // Default to string for unknown types + } + } else { + s.Type = genai.TypeString // Default to string if type is not specified + } + if desc, ok := defMap["description"].(string); ok { + s.Description = desc + } + props.Properties[name] = s + } + } + } + required := []string{} + if req, ok := decl.Parameters["required"].([]string); ok { + required = req + } + props.Required = required + + modelTools = append(modelTools, &genai.FunctionDeclaration{ + Name: decl.Name, + Description: decl.Description, + Parameters: props, + }) + } + model.Tools = []*genai.Tool{ + { + FunctionDeclarations: modelTools, + }, + } + + // System Instruction + instr, _ := e.agent.GetInstructions(ctx, state) + + // Inject schema into context for ProcessLLMRequest + var rawSchema map[string]interface{} + if val, ok := state[a2uiSchemaKey].(map[string]interface{}); ok { + rawSchema = val + } + + ctxWithSchema := ctx + if rawSchema != nil { + ctxWithSchema = context.WithValue(ctx, schemaContextKey, rawSchema) + } + + // Collect additional instructions from tools (e.g. A2UI schema) + llmReq := &a2ui.LlmRequest{} + for _, t := range e.agent.Tools { + if err := t.ProcessLLMRequest(ctxWithSchema, nil, llmReq); err != nil { + log.Printf("Tool %s ProcessLLMRequest failed: %v", t.Name(), err) + } + } + + // Append tool instructions + for _, toolInstr := range llmReq.Instructions { + instr += "\n" + toolInstr + } + + log.Printf("System Instruction Length: %d", len(instr)) + + model.SystemInstruction = &genai.Content{Parts: []genai.Part{genai.Text(instr)}} + + cs := model.StartChat() + + // Send Message + log.Println("Executor: Sending message to Gemini...") + resp, err := cs.SendMessage(ctx, genai.Text(userText)) + if err != nil { + log.Printf("Gemini SendMessage failed: %v", err) + return err + } + + var responseText string + var a2uiPayloads []map[string]interface{} + + // Handle Tool Loop + for { + if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + break + } + + var functionCalls []genai.FunctionCall + + // Reset responseText for the current turn to avoid accumulating redundant text history + // from previous turns (e.g. "I will do X" ... "I have done X"). + // We only want the latest text response. + responseText = "" + + // Scan all parts for function calls or text + for _, part := range resp.Candidates[0].Content.Parts { + if fc, ok := part.(genai.FunctionCall); ok { + functionCalls = append(functionCalls, fc) + } else if txt, ok := part.(genai.Text); ok { + responseText += string(txt) + } + } + + // If no function calls, we are done + if len(functionCalls) == 0 { + break + } + + // Execute all function calls + var functionResponses []genai.Part + for _, fc := range functionCalls { + log.Printf("Gemini called tool: %s", fc.Name) + + // Find tool + var selectedTool a2ui.BaseTool + for _, t := range e.agent.Tools { + if t.Name() == fc.Name { + selectedTool = t + break + } + } + + var toolResult map[string]interface{} + if selectedTool != nil { + // Execute + toolArgs := make(map[string]interface{}) + for k, v := range fc.Args { + toolArgs[k] = v + } + + // Inject schema into context + ctxWithSchema := ctx + if rawSchema != nil { + log.Println("Executor: Injecting schema into context for tool execution") + ctxWithSchema = context.WithValue(ctx, schemaContextKey, rawSchema) + } else { + log.Println("Executor: Warning: rawSchema is nil") + } + // Run + res, err := selectedTool.Run(ctxWithSchema, toolArgs, nil) + if err != nil { + log.Printf("Executor: Tool execution failed: %v", err) + toolResult = map[string]interface{}{"error": err.Error()} + } else { + toolResult = res + // Capture A2UI payload if it's the send tool + if fc.Name == "send_a2ui_json_to_client" { + log.Println("Executor: Processing send_a2ui_json_to_client response") + if validated, ok := res["validated_a2ui_json"].([]interface{}); ok { + log.Printf("Executor: Found %d validated payloads", len(validated)) + for _, v := range validated { + if m, ok := v.(map[string]interface{}); ok { + a2uiPayloads = append(a2uiPayloads, m) + } + } + } else { + log.Println("Executor: validated_a2ui_json missing or invalid type") + } + } + } + } else { + log.Printf("Executor: Tool %s not found in agent tools", fc.Name) + toolResult = map[string]interface{}{"error": "tool not found"} + } + + functionResponses = append(functionResponses, genai.FunctionResponse{ + Name: fc.Name, + Response: toolResult, + }) + } + + // Send responses back + resp, err = cs.SendMessage(ctx, functionResponses...) + if err != nil { + log.Printf("Gemini SendMessage (func response) failed: %v", err) + return err + } + } + + // Construct Final Response + log.Printf("Executor: Captured %d A2UI payloads", len(a2uiPayloads)) + + var allParts []a2a.Part + + // Add text if present + if responseText != "" { + allParts = append(allParts, &a2a.TextPart{Text: responseText}) + } + + // Add artifacts and send Data Message if present + var dataParts []a2a.Part + for _, payload := range a2uiPayloads { + dp := &a2a.DataPart{ + Data: payload, + Metadata: map[string]interface{}{ + a2ui.MIMETypeKey: a2ui.MIMEType, + }, + } + dataParts = append(dataParts, dp) + allParts = append(allParts, dp) + } + + if len(dataParts) > 0 { + log.Println("Executor: Sending TaskArtifactUpdateEvent") + artifactEvent := a2a.NewArtifactEvent(reqCtx, dataParts...) + if err := queue.Write(ctx, artifactEvent); err != nil { + log.Printf("Executor: Failed to write artifact event: %v", err) + return fmt.Errorf("failed to write artifact event: %w", err) + } + } + + if len(allParts) > 0 { + log.Println("Executor: Sending Combined Response Message") + msg := a2a.NewMessageForTask(a2a.MessageRole("model"), reqCtx, allParts...) + if err := queue.Write(ctx, msg); err != nil { + log.Printf("Executor: Failed to write response message: %v", err) + return fmt.Errorf("failed to write response message: %w", err) + } + } + + log.Println("Executor: Sending TaskStateCompleted (Final)") + completeEvent := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCompleted, nil) + completeEvent.Final = true + if err := queue.Write(ctx, completeEvent); err != nil { + log.Printf("Executor: Failed to write completed event: %v", err) + return fmt.Errorf("failed to write state completed: %w", err) + } + + log.Println("Executor: Execute finished successfully") + return nil +} + +// Cancel implements a2asrv.AgentExecutor. +func (e *RizzchartsAgentExecutor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { + event := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCanceled, nil) + event.Final = true + return queue.Write(ctx, event) +} + +// Helper providers +func GetA2UISchema(state map[string]interface{}) map[string]interface{} { + if val, ok := state[a2uiSchemaKey].(map[string]interface{}); ok { + return val + } + return nil +} + +func GetA2UIEnabled(state map[string]interface{}) bool { + if val, ok := state[a2uiEnabledKey].(bool); ok { + return val + } + return false +} diff --git a/samples/agent/adk/rizzcharts-go/go.mod b/samples/agent/adk/rizzcharts-go/go.mod new file mode 100644 index 000000000..9d3a3e6e0 --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/go.mod @@ -0,0 +1,48 @@ +module github.com/google/A2UI/samples/agent/adk/rizzcharts-go + +go 1.24.8 + +replace github.com/google/A2UI/a2a_agents/go => ../../../../a2a_agents/go + +require ( + github.com/a2aproject/a2a-go v0.3.5 + github.com/google/A2UI/a2a_agents/go v0.0.0-00010101000000-000000000000 + github.com/google/generative-ai-go v0.20.1 + github.com/joho/godotenv v1.5.1 + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + google.golang.org/api v0.263.0 +) + +require ( + cloud.google.com/go v0.115.0 // indirect + cloud.google.com/go/ai v0.8.0 // indirect + cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/longrunning v0.5.7 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.16.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/samples/agent/adk/rizzcharts-go/go.sum b/samples/agent/adk/rizzcharts-go/go.sum new file mode 100644 index 000000000..fef9436ea --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/go.sum @@ -0,0 +1,102 @@ +cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= +cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= +cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w= +cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE= +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU= +cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng= +github.com/a2aproject/a2a-go v0.3.5 h1:sc6p5sVrahAtO0De14iIKt2RCbIu7qp0ZUdaxNufopw= +github.com/a2aproject/a2a-go v0.3.5/go.mod h1:8C0O6lsfR7zWFEqVZz/+zWCoxe8gSWpknEpqm/Vgj3E= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/generative-ai-go v0.20.1 h1:6dEIujpgN2V0PgLhr6c/M1ynRdc7ARtiIDPFzj45uNQ= +github.com/google/generative-ai-go v0.20.1/go.mod h1:TjOnZJmZKzarWbjUJgy+r3Ee7HGBRVLhOIgupnwR4Bg= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= +github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.263.0 h1:UFs7qn8gInIdtk1ZA6eXRXp5JDAnS4x9VRsRVCeKdbk= +google.golang.org/api v0.263.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8= +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-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/samples/agent/adk/rizzcharts-go/main.go b/samples/agent/adk/rizzcharts-go/main.go new file mode 100644 index 000000000..3fc697997 --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/main.go @@ -0,0 +1,213 @@ +package main + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "context" + "flag" + "fmt" + "log" + "log/slog" + "net/http" + "os" + "sync" + + "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/a2asrv" + "github.com/google/A2UI/a2a_agents/go/a2ui" + "github.com/joho/godotenv" +) + +// InMemoryTaskStore implementation +type InMemoryTaskStore struct { + mu sync.RWMutex + tasks map[a2a.TaskID]*a2a.Task +} + +func NewInMemoryTaskStore() *InMemoryTaskStore { + return &InMemoryTaskStore{ + tasks: make(map[a2a.TaskID]*a2a.Task), + } +} + +func (s *InMemoryTaskStore) Save(ctx context.Context, task *a2a.Task, event a2a.Event, prev a2a.TaskVersion) (a2a.TaskVersion, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // Basic optimistic concurrency check (ignored for sample simplicity if prev is empty) + // In a real store, check if existing task version matches prev. + + // Create a deep copy or just store the pointer (for in-memory sample, pointer is risky but okay for simple usage) + // To be safe, we should clone, but a2a.Task is complex. Storing the pointer for now. + s.tasks[task.ID] = task + + // Return new version (using timestamp or incremental counter). + // a2a.TaskVersion is int64. + return a2a.TaskVersion(len(task.History)), nil +} + +func (s *InMemoryTaskStore) Get(ctx context.Context, taskID a2a.TaskID) (*a2a.Task, a2a.TaskVersion, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + task, ok := s.tasks[taskID] + if !ok { + return nil, 0, a2a.ErrTaskNotFound + } + return task, a2a.TaskVersion(len(task.History)), nil +} + +func (s *InMemoryTaskStore) List(ctx context.Context, req *a2a.ListTasksRequest) (*a2a.ListTasksResponse, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var tasks []*a2a.Task + for _, t := range s.tasks { + tasks = append(tasks, t) + } + // Pagination logic would go here + return &a2a.ListTasksResponse{Tasks: tasks}, nil +} + +// Context key for passing schema +type contextKey string + +const schemaContextKey contextKey = "a2ui_schema" + +// Main entry point +func main() { + // Load environment variables from .env file if it exists + if err := godotenv.Load(); err != nil { + log.Println("No .env file found or error loading it") + } + + // Define flags for host and port + host := flag.String("host", "localhost", "Host to bind to") + port := flag.Int("port", 10002, "Port to bind to") + flag.Parse() + + // Check for API key + if os.Getenv("GOOGLE_GENAI_USE_VERTEXAI") != "TRUE" { + if os.Getenv("GEMINI_API_KEY") == "" { + log.Fatal("Error: GEMINI_API_KEY environment variable not set and GOOGLE_GENAI_USE_VERTEXAI is not TRUE.") + } + } + + baseURL := fmt.Sprintf("http://%s:%d", *host, *port) + + // Load schema and catalog contents + schemaContent, err := os.ReadFile("../../../../specification/v0_8/json/server_to_client.json") + if err != nil { + log.Fatalf("Failed to read schema: %v", err) + } + standardCatalogContent, err := os.ReadFile("../../../../specification/v0_8/json/standard_catalog_definition.json") + if err != nil { + log.Fatalf("Failed to read standard catalog: %v", err) + } + rizzchartsCatalogContent, err := os.ReadFile("rizzcharts_catalog_definition.json") + if err != nil { + log.Fatalf("Failed to read rizzcharts catalog: %v", err) + } + + catalogBuilder := NewComponentCatalogBuilder( + string(schemaContent), + map[string]string{ + a2ui.StandardCatalogID: string(standardCatalogContent), + RizzchartsCatalogURI: string(rizzchartsCatalogContent), + }, + a2ui.StandardCatalogID, + ) + + // Providers + enabledProvider := func(ctx context.Context) (bool, error) { + return true, nil + } + + schemaProvider := func(ctx context.Context) (map[string]interface{}, error) { + if val := ctx.Value(schemaContextKey); val != nil { + return val.(map[string]interface{}), nil + } + return nil, fmt.Errorf("A2UI schema not found in context") + } + + agent := NewRizzchartsAgent(enabledProvider, schemaProvider) + executor := NewRizzchartsAgentExecutor(baseURL, catalogBuilder, agent) + + // Setup A2A Server components + taskStore := NewInMemoryTaskStore() + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + // Create Request Handler + requestHandler := a2asrv.NewHandler( + executor, + a2asrv.WithTaskStore(taskStore), + a2asrv.WithLogger(logger), + ) + + // Middleware for CORS + enableCORS := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE") + w.Header().Set("Access-Control-Allow-Headers", "*") + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Debug: Log headers to check for X-A2A-Extensions + log.Printf("Received %s request to %s with Headers: %v", r.Method, r.URL.Path, r.Header) + + exts := r.Header.Values("X-A2a-Extensions") + + if len(exts) > 0 { + log.Printf("Found A2UI Extensions in header: %v. Injecting into context.", exts) + meta := a2asrv.NewRequestMeta(map[string][]string{ + a2asrv.ExtensionsMetaKey: exts, + }) + // a2asrv.WithCallContext returns (ctx, callContext). We need the ctx. + ctx, _ := a2asrv.WithCallContext(r.Context(), meta) + r = r.WithContext(ctx) + } else { + log.Println("No A2UI Extensions found in header.") + } + + next.ServeHTTP(w, r) + }) + } + + mux := http.NewServeMux() + + // Agent Card Endpoint + agentCardHandler := a2asrv.NewStaticAgentCardHandler(executor.GetAgentCard()) + mux.Handle("/.well-known/agent-card.json", agentCardHandler) + + // A2A JSON-RPC Handler + jsonRPCHandler := a2asrv.NewJSONRPCHandler(requestHandler) + mux.Handle("/", jsonRPCHandler) + + addr := fmt.Sprintf("%s:%d", *host, *port) + log.Printf("Starting server on %s", baseURL) + + // Wrap mux with CORS + if err := http.ListenAndServe(addr, enableCORS(mux)); err != nil { + log.Fatalf("Server failed: %v", err) + } +} diff --git a/samples/agent/adk/rizzcharts-go/main_test.go b/samples/agent/adk/rizzcharts-go/main_test.go new file mode 100644 index 000000000..950b4e1f0 --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/main_test.go @@ -0,0 +1,222 @@ +package main + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/a2asrv" + "github.com/google/A2UI/a2a_agents/go/a2ui" +) + +func init() { + // Set dummy API key for tests to pass validation + os.Setenv("GEMINI_API_KEY", "test-key") +} + +// Helper to setup catalog builder for tests +func setupCatalogBuilder(t *testing.T) *ComponentCatalogBuilder { + schemaContent, err := os.ReadFile("../../../../specification/v0_8/json/server_to_client.json") + if err != nil { + t.Fatalf("Failed to read schema: %v", err) + } + standardCatalogContent, err := os.ReadFile("../../../../specification/v0_8/json/standard_catalog_definition.json") + if err != nil { + t.Fatalf("Failed to read standard catalog: %v", err) + } + rizzchartsCatalogContent, err := os.ReadFile("rizzcharts_catalog_definition.json") + if err != nil { + t.Fatalf("Failed to read rizzcharts catalog: %v", err) + } + + return NewComponentCatalogBuilder( + string(schemaContent), + map[string]string{ + a2ui.StandardCatalogID: string(standardCatalogContent), + RizzchartsCatalogURI: string(rizzchartsCatalogContent), + }, + a2ui.StandardCatalogID, + ) +} + +func TestGetAgentCard(t *testing.T) { + builder := setupCatalogBuilder(t) + agent := NewRizzchartsAgent(func(ctx context.Context) (bool, error) { return true, nil }, func(ctx context.Context) (map[string]interface{}, error) { return nil, nil }) + executor := NewRizzchartsAgentExecutor("http://localhost:10002", builder, agent) + card := executor.GetAgentCard() + + if card.Name != "Ecommerce Dashboard Agent" { + t.Errorf("Expected agent name 'Ecommerce Dashboard Agent', got '%s'", card.Name) + } + if len(card.Skills) != 2 { + t.Errorf("Expected 2 skills, got %d", len(card.Skills)) + } + foundExt := false + for _, ext := range card.Capabilities.Extensions { + if ext.URI == a2ui.ExtensionURI { + foundExt = true + break + } + } + if !foundExt { + t.Error("Expected A2UI extension in capabilities") + } +} + +func TestTools(t *testing.T) { + // Test GetStoreSalesTool + storeTool := &GetStoreSalesTool{} + if storeTool.Name() != "get_store_sales" { + t.Errorf("Expected tool name 'get_store_sales', got '%s'", storeTool.Name()) + } + res, err := storeTool.Run(context.Background(), map[string]interface{}{"region": "all"}, nil) + if err != nil { + t.Fatalf("GetStoreSalesTool failed: %v", err) + } + if res["locations"] == nil { + t.Error("Expected locations in response") + } + + // Test GetSalesDataTool + salesTool := &GetSalesDataTool{} + if salesTool.Name() != "get_sales_data" { + t.Errorf("Expected tool name 'get_sales_data', got '%s'", salesTool.Name()) + } + res, err = salesTool.Run(context.Background(), map[string]interface{}{"time_period": "Q1"}, nil) + if err != nil { + t.Fatalf("GetSalesDataTool failed: %v", err) + } + if res["sales_data"] == nil { + t.Error("Expected sales_data in response") + } +} + +func TestAgentInstructions(t *testing.T) { + builder := setupCatalogBuilder(t) + + // Mock providers + enabledProvider := func(ctx context.Context) (bool, error) { return true, nil } + schemaProvider := func(ctx context.Context) (map[string]interface{}, error) { return nil, nil } + + agent := NewRizzchartsAgent(enabledProvider, schemaProvider) + + // Manually populate state as PrepareSession would + schema, uri, err := builder.LoadA2UISchema(map[string]interface{}{ + a2ui.SupportedCatalogIDsKey: []interface{}{RizzchartsCatalogURI}, + }) + if err != nil { + t.Fatalf("Failed to load schema: %v", err) + } + + state := map[string]interface{}{ + a2uiEnabledKey: true, + a2uiSchemaKey: schema, + A2UICatalogURIStateKey: uri, + } + + instr, err := agent.GetInstructions(context.Background(), state) + if err != nil { + t.Fatalf("GetInstructions failed: %v", err) + } + + if !strings.Contains(instr, "---BEGIN CHART EXAMPLE---") { + t.Error("Instructions missing chart example") + } + if !strings.Contains(instr, "send_a2ui_json_to_client") { + t.Error("Instructions missing tool call instructions") + } +} + +func TestAgentCardEndpoint(t *testing.T) { + // Setup request + req := httptest.NewRequest("GET", "/.well-known/agent-card.json", nil) + w := httptest.NewRecorder() + + // Recreate the handler logic from main (simplified) + builder := setupCatalogBuilder(t) + agent := NewRizzchartsAgent(func(ctx context.Context) (bool, error) { return true, nil }, func(ctx context.Context) (map[string]interface{}, error) { return nil, nil }) + executor := NewRizzchartsAgentExecutor("http://localhost:test", builder, agent) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + card := executor.GetAgentCard() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(card); err != nil { + http.Error(w, "Failed to encode agent card", http.StatusInternalServerError) + } + }) + + handler.ServeHTTP(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + if resp.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected content type application/json, got %s", resp.Header.Get("Content-Type")) + } + + var card a2a.AgentCard + if err := json.NewDecoder(resp.Body).Decode(&card); err != nil { + t.Fatalf("Failed to decode agent card: %v", err) + } + if card.Name != "Ecommerce Dashboard Agent" { + t.Errorf("Expected agent name 'Ecommerce Dashboard Agent', got '%s'", card.Name) + } +} + +func TestPrepareSession(t *testing.T) { + builder := setupCatalogBuilder(t) + agent := NewRizzchartsAgent(func(ctx context.Context) (bool, error) { return true, nil }, func(ctx context.Context) (map[string]interface{}, error) { return nil, nil }) + executor := NewRizzchartsAgentExecutor("http://localhost:test", builder, agent) + state := make(map[string]interface{}) + + // Context with A2UI requested + reqMeta := a2asrv.NewRequestMeta(map[string][]string{ + a2asrv.ExtensionsMetaKey: {a2ui.ExtensionURI}, + }) + ctx, _ := a2asrv.WithCallContext(context.Background(), reqMeta) + + reqCtx := &a2asrv.RequestContext{ + Message: &a2a.Message{ + Metadata: map[string]interface{}{ + a2ui.ClientCapabilitiesKey: map[string]interface{}{ + a2ui.SupportedCatalogIDsKey: []interface{}{RizzchartsCatalogURI}, + }, + }, + }, + } + + err := executor.PrepareSession(ctx, state, reqCtx) + if err != nil { + t.Fatalf("PrepareSession failed: %v", err) + } + + if state[a2uiEnabledKey] != true { + t.Error("Expected A2UI enabled in state") + } + if state[A2UICatalogURIStateKey] != RizzchartsCatalogURI { + t.Errorf("Expected catalog URI %s, got %v", RizzchartsCatalogURI, state[A2UICatalogURIStateKey]) + } + if state[a2uiSchemaKey] == nil { + t.Error("Expected schema in state") + } +} diff --git a/samples/agent/adk/rizzcharts-go/rizzcharts-go b/samples/agent/adk/rizzcharts-go/rizzcharts-go new file mode 100755 index 000000000..c598eade9 Binary files /dev/null and b/samples/agent/adk/rizzcharts-go/rizzcharts-go differ diff --git a/samples/agent/adk/rizzcharts-go/rizzcharts_catalog_definition.json b/samples/agent/adk/rizzcharts-go/rizzcharts_catalog_definition.json new file mode 100644 index 000000000..22c388139 --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/rizzcharts_catalog_definition.json @@ -0,0 +1,158 @@ +{ + "components": { + "$ref": "../../../../specification/v0_8/json/standard_catalog_definition.json#/components", + "Canvas": { + "type": "object", + "description": "Renders the UI element in a stateful panel next to the chat window.", + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children.", + "properties": { + "explicitList": { + "type": "array", + "items": {"type": "string"} + } + } + } + }, + "required": [ + "children" + ] + }, + "Chart": { + "type": "object", + "description": "An interactive chart that uses a hierarchical list of objects for its data.", + "properties": { + "type": { + "type": "string", + "description": "The type of chart to render.", + "enum": [ + "doughnut", + "pie" + ] + }, + "title": { + "type": "object", + "description": "The title of the chart. Can be a literal string or a data model path.", + "properties": { + "literalString": {"type": "string"}, + "path": {"type": "string"} + } + }, + "chartData": { + "type": "object", + "description": "The data for the chart, provided as a list of items. Can be a literal array or a data model path.", + "properties": { + "literalArray": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "value": {"type": "number"}, + "drillDown": { + "type": "array", + "description": "An optional list of items for the next level of data.", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "value": {"type": "number"} + }, + "required": [ + "label", + "value" + ] + } + } + }, + "required": [ + "label", + "value" + ] + } + }, + "path": {"type": "string"} + } + } + }, + "required": [ + "type", + "chartData" + ] + }, + "GoogleMap": { + "type": "object", + "description": "A component to display a Google Map with pins.", + "properties": { + "center": { + "type": "object", + "description": "The center point of the map, containing latitude and longitude. Can be a literal object or a data model path.", + "properties": { + "literalObject": { + "type": "object", + "properties": { + "lat": {"type": "number"}, + "lng": {"type": "number"} + }, + "required": [ + "lat", + "lng" + ] + }, + "path": {"type": "string"} + } + }, + "zoom": { + "type": "object", + "description": "The zoom level of the map. Can be a literal number or a data model path.", + "properties": { + "literalNumber": {"type": "number"}, + "path": {"type": "string"} + } + }, + "pins": { + "type": "object", + "description": "A list of pin objects to display on the map. Can be a literal array or a data model path.", + "properties": { + "literalArray": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lat": {"type": "number"}, + "lng": {"type": "number"}, + "name": {"type": "string"}, + "description": {"type": "string"}, + "background": { + "type": "string", + "description": "Hex color code for the pin background (e.g., '#FBBC04')." + }, + "borderColor": { + "type": "string", + "description": "Hex color code for the pin border (e.g., '#000000')." + }, + "glyphColor": { + "type": "string", + "description": "Hex color code for the pin's glyph/icon (e.g., '#000000')." + } + }, + "required": [ + "lat", + "lng", + "name" + ] + } + }, + "path": {"type": "string"} + } + } + }, + "required": [ + "center", + "zoom" + ] + } + } +} diff --git a/samples/agent/adk/rizzcharts-go/tools.go b/samples/agent/adk/rizzcharts-go/tools.go new file mode 100644 index 000000000..25c102045 --- /dev/null +++ b/samples/agent/adk/rizzcharts-go/tools.go @@ -0,0 +1,163 @@ +package main + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "context" + "log" + + "github.com/google/A2UI/a2a_agents/go/a2ui" +) + +// GetStoreSalesTool retrieves individual store sales. +type GetStoreSalesTool struct{} + +func (t *GetStoreSalesTool) Name() string { + return "get_store_sales" +} + +func (t *GetStoreSalesTool) Description() string { + return "Gets individual store sales" +} + +func (t *GetStoreSalesTool) GetDeclaration() *a2ui.FunctionDeclaration { + return &a2ui.FunctionDeclaration{ + Name: t.Name(), + Description: t.Description(), + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "region": map[string]interface{}{ + "type": "string", + "description": "The region to get store sales for.", + "default": "all", + }, + }, + "required": []string{}, + }, + } +} + +func (t *GetStoreSalesTool) ProcessLLMRequest(ctx context.Context, toolContext *a2ui.ToolContext, llmRequest *a2ui.LlmRequest) error { + return nil +} + +func (t *GetStoreSalesTool) Run(ctx context.Context, args map[string]interface{}, toolContext *a2ui.ToolContext) (map[string]interface{}, error) { + region, _ := args["region"].(string) + if region == "" { + region = "all" + } + log.Printf("get_store_sales called with region=%s", region) + + return map[string]interface{}{ + "center": map[string]interface{}{"lat": 34, "lng": -118.2437}, + "zoom": 10, + "locations": []interface{}{ + map[string]interface{}{ + "lat": 34.0195, + "lng": -118.4912, + "name": "Santa Monica Branch", + "description": "High traffic coastal location.", + "outlier_reason": "Yes, 15% sales over baseline", + "background": "#4285F4", + "borderColor": "#FFFFFF", + "glyphColor": "#FFFFFF", + }, + map[string]interface{}{"lat": 34.0488, "lng": -118.2518, "name": "Downtown Flagship"}, + map[string]interface{}{"lat": 34.1016, "lng": -118.3287, "name": "Hollywood Boulevard Store"}, + map[string]interface{}{"lat": 34.1478, "lng": -118.1445, "name": "Pasadena Location"}, + map[string]interface{}{"lat": 33.7701, "lng": -118.1937, "name": "Long Beach Outlet"}, + map[string]interface{}{"lat": 34.0736, "lng": -118.4004, "name": "Beverly Hills Boutique"}, + }, + }, nil +} + +// GetSalesDataTool retrieves sales data. +type GetSalesDataTool struct{} + +func (t *GetSalesDataTool) Name() string { + return "get_sales_data" +} + +func (t *GetSalesDataTool) Description() string { + return "Gets the sales data." +} + +func (t *GetSalesDataTool) GetDeclaration() *a2ui.FunctionDeclaration { + return &a2ui.FunctionDeclaration{ + Name: t.Name(), + Description: t.Description(), + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "time_period": map[string]interface{}{ + "type": "string", + "description": "The time period to get sales data for (e.g. 'Q1', 'year'). Defaults to 'year'.", + "default": "year", + }, + }, + "required": []string{}, + }, + } +} + +func (t *GetSalesDataTool) ProcessLLMRequest(ctx context.Context, toolContext *a2ui.ToolContext, llmRequest *a2ui.LlmRequest) error { + return nil +} + +func (t *GetSalesDataTool) Run(ctx context.Context, args map[string]interface{}, toolContext *a2ui.ToolContext) (map[string]interface{}, error) { + timePeriod, _ := args["time_period"].(string) + if timePeriod == "" { + timePeriod = "year" + } + log.Printf("get_sales_data called with time_period=%s", timePeriod) + + return map[string]interface{}{ + "sales_data": []interface{}{ + map[string]interface{}{ + "label": "Apparel", + "value": 41, + "drillDown": []interface{}{ + map[string]interface{}{"label": "Tops", "value": 31}, + map[string]interface{}{"label": "Bottoms", "value": 38}, + map[string]interface{}{"label": "Outerwear", "value": 20}, + map[string]interface{}{"label": "Footwear", "value": 11}, + }, + }, + map[string]interface{}{ + "label": "Home Goods", + "value": 15, + "drillDown": []interface{}{ + map[string]interface{}{"label": "Pillow", "value": 8}, + map[string]interface{}{"label": "Coffee Maker", "value": 16}, + map[string]interface{}{"label": "Area Rug", "value": 3}, + map[string]interface{}{"label": "Bath Towels", "value": 14}, + }, + }, + map[string]interface{}{ + "label": "Electronics", + "value": 28, + "drillDown": []interface{}{ + map[string]interface{}{"label": "Phones", "value": 25}, + map[string]interface{}{"label": "Laptops", "value": 27}, + map[string]interface{}{"label": "TVs", "value": 21}, + map[string]interface{}{"label": "Other", "value": 27}, + }, + }, + map[string]interface{}{"label": "Health & Beauty", "value": 10}, + map[string]interface{}{"label": "Other", "value": 6}, + }, + }, nil +} diff --git a/samples/agent/adk/uv.lock b/samples/agent/adk/uv.lock index 48cf6be77..bbba9aba0 100644 --- a/samples/agent/adk/uv.lock +++ b/samples/agent/adk/uv.lock @@ -52,6 +52,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "pyink", specifier = ">=24.10.0" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, ] @@ -1145,7 +1146,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, - { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, @@ -1153,7 +1153,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, @@ -1161,7 +1160,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },