From 0fc76ab1233baaa49d2e16a1f124c4aa95075b65 Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Tue, 21 Oct 2025 14:56:45 +0000 Subject: [PATCH 01/15] helloworld example --- examples/grpc/helloworld/client/main.go | 46 +++++++++ examples/grpc/helloworld/server/main.go | 125 ++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 examples/grpc/helloworld/client/main.go create mode 100644 examples/grpc/helloworld/server/main.go diff --git a/examples/grpc/helloworld/client/main.go b/examples/grpc/helloworld/client/main.go new file mode 100644 index 00000000..44693ddb --- /dev/null +++ b/examples/grpc/helloworld/client/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "flag" + "log" + + "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/a2aclient" + "github.com/a2aproject/a2a-go/a2aclient/agentcard" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +var cardURL = flag.String("card-url", "http://127.0.0.1:9001", "Base URL of AgentCard server.") + +func main() { + flag.Parse() + ctx := context.Background() + + // Resolve an AgentCard + cardResolver := agentcard.Resolver{BaseURL: *cardURL} + card, err := cardResolver.Resolve(ctx) + if err != nil { + log.Fatalf("Failed to resolve an AgentCard: %v", err) + } + + // Insecure connection is used for example purposes + withInsecureGRPC := a2aclient.WithGRPCTransport(grpc.WithTransportCredentials(insecure.NewCredentials())) + + // Create a client connected to one of the interfaces specified in the AgentCard. + client, err := a2aclient.NewFromCard(ctx, card, withInsecureGRPC) + if err != nil { + log.Fatalf("Failed to create a client: %v", err) + } + + // Send a message and log the response. + msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.TextPart{Text: "Hello, world"}) + resp, err := client.SendMessage(ctx, &a2a.MessageSendParams{Message: msg}) + if err != nil { + log.Fatalf("Failed to send a message: %v", err) + } + + log.Printf("Server responded with: %+v", resp) +} diff --git a/examples/grpc/helloworld/server/main.go b/examples/grpc/helloworld/server/main.go new file mode 100644 index 00000000..38b8ef5a --- /dev/null +++ b/examples/grpc/helloworld/server/main.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + + "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/a2agrpc" + "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/a2asrv/eventqueue" + "golang.org/x/sync/errgroup" + "google.golang.org/grpc" +) + +// agentExecutor implements [a2asrv.AgentExecutor], which is a required [a2asrv.RequestHandler] dependency. +// It is responsible for invoking an agent, translating its outputs to a2a.Event object and writing them to the provided [eventqueue.Queue]. +type agentExecutor struct{} + +func (*agentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestContext, q eventqueue.Queue) error { + response := a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "Hello, world!"}) + return q.Write(ctx, response) +} + +func (*agentExecutor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestContext, q eventqueue.Queue) error { + return nil +} + +// agentCardProducer implements [a2asrv.AgentCardProducer], which is a required [a2agrpc.GRPCHandler] dependency. +// It is responsible for creating an a2a.AgentCard describing the agent and its capabilities. +type agentCardProducer struct { + grpcPort int +} + +func (p *agentCardProducer) Card() *a2a.AgentCard { + return &a2a.AgentCard{ + Name: "Hello World Agent", + Description: "Just a hello world agent", + URL: fmt.Sprintf("127.0.0.1:%d", p.grpcPort), + PreferredTransport: a2a.TransportProtocolGRPC, + DefaultInputModes: []string{"text"}, + DefaultOutputModes: []string{"text"}, + Capabilities: a2a.AgentCapabilities{Streaming: true}, + Skills: []a2a.AgentSkill{ + { + ID: "hello_world", + Name: "Hello, world!", + Description: "Returns a 'Hello, world!'", + Tags: []string{"hello world"}, + Examples: []string{"hi", "hello"}, + }, + }, + } +} + +func startGRPCServer(port int, card a2asrv.AgentCardProducer) error { + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return err + } + log.Printf("Starting a gRPC server on 127.0.0.1:%d", port) + + // A transport-agnostic implementation of A2A protocol methods. + // The behavior is configurable using option-arguments of form a2asrv.With*(), for example: + // a2asrv.NewHandler(executor, a2asrv.WithTaskStore(customStore)) + requestHandler := a2asrv.NewHandler(&agentExecutor{}) + + // A gRPC-transport implementation for A2A. + grpcHandler := a2agrpc.NewHandler(card, requestHandler) + + s := grpc.NewServer() + grpcHandler.RegisterWith(s) + return s.Serve(listener) +} + +func servePublicCard(port int, card a2asrv.AgentCardProducer) error { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return err + } + + log.Printf("Starting a public AgentCard server on 127.0.0.1:%d", port) + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/agent-card.json", func(w http.ResponseWriter, r *http.Request) { + jsonData, err := json.Marshal(card.Card()) + if err != nil { + log.Printf("Error encoding JSON: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(jsonData); err != nil { + log.Printf("Error serving AgentCard: %v", err) + } else { + log.Print("AgentCard request handled") + } + }) + return http.Serve(listener, mux) +} + +var ( + grpcPort = flag.Int("grpc-port", 9000, "Port for a gGRPC A2A server to listen on.") + cardPort = flag.Int("card-port", 9001, "Port for a public A2A AgentCard server to listen on.") +) + +func main() { + flag.Parse() + cardProducer := &agentCardProducer{grpcPort: *grpcPort} + + var group errgroup.Group + group.Go(func() error { + return startGRPCServer(*grpcPort, cardProducer) + }) + group.Go(func() error { + return servePublicCard(*cardPort, cardProducer) + }) + if err := group.Wait(); err != nil { + log.Fatalf("Server shutdown: %v", err) + } +} From 746e65af014e803b8f461cc6a2871023b9195ecf Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Tue, 21 Oct 2025 14:56:54 +0000 Subject: [PATCH 02/15] readme doc --- README.md | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 68fd67c9..777fef68 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,105 @@ -# a2a-go -Golang SDK for A2A Protocol +# A2A Go SDK + +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +[![Nightly Check](https://github.com/a2aproject/a2a-go/actions/workflows/nightly.yml/badge.svg)](https://github.com/a2aproject/a2a-go/actions/workflows/nightly.yml) +[![Go Doc](https://img.shields.io/badge/Go%20Package-Doc-blue.svg)](https://pkg.go.dev/github.com/a2aproject/a2a-go) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/a2aproject/a2a-go) + + + +
+ A2A Logo +

+ A Go library for running agentic applications as A2A Servers, following the Agent2Agent (A2A) Protocol. +

+
+ + + +--- + +## โœจ Features + +- **A2A Protocol Compliant:** Build agentic applications that adhere to the Agent2Agent (A2A) Protocol. +- **Extensible:** Easily add support for different communication protocols and database backends. + +--- + +## ๐Ÿš€ Getting Started + +Requires Go `1.24.4` or newer: +```bash +go get github.com/a2aproject/a2a-go@v0.3.0 +``` + +## Examples + +For a simple example refer to [gRPC helloworld](./examples/grpc/helloworld). + +### Server + +1. Create a transport-agnostic A2A request handler: + + ```go + var options []a2asrv.RequestHandlerOption = newCustomOptions() + var agentExecutor a2asrv.AgentExecutor = newCustomAgentExecutor() + requestHandler := a2asrv.NewHandler(agentExecutor, options...) + ``` + +2. Wrap the handler into a transport implementation: + + ```go + var cardProducer a2asrv.AgentCardProducer = newCustomCardProducer() + grpcHandler := a2agrpc.NewHandler(cardProducer, requestHandler) + ``` + +3. Register handler with a server, for example: + ```go + import "google.golang.org/grpc" + + ... + + server := grpc.NewServer() + grpcHandler.RegisterWith(server) + err := server.Serve(listener) + ``` + +### Client + +1. Resolve an `AgentCard` to get an information about how an agent is exposed. + + ```go + cardResolver := agentcard.Resolver{BaseURL: *cardURL} + card, err := cardResolver.Resolve(ctx) + ``` + +2. Create a transport-agnostic client from the `AgentCard`: + + ```go + var options a2aclient.FactoryOption = newCustomClientOptions() + client, err := a2aclient.NewFromCard(ctx, card, options...) + ``` + +3. The connection is now open and can be used to send requests to a server: + ```go + msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.TextPart{Text: "..."}) + resp, err := client.SendMessage(ctx, &a2a.MessageSendParams{Message: msg}) + ``` + +--- + +## ๐ŸŒ More Examples + +You can find a variety of more detailed examples in the [a2a-samples](https://github.com/a2aproject/a2a-samples) repository. + +--- + +## ๐Ÿค Contributing + +Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to get involved. + +--- + +## ๐Ÿ“„ License + +This project is licensed under the Apache 2.0 License. See the [LICENSE](LICENSE) file for more details. From ee663f79507e54560dd8e2cdd99dd4d61e847551 Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Wed, 22 Oct 2025 07:22:00 +0000 Subject: [PATCH 03/15] licenses --- examples/grpc/helloworld/client/main.go | 14 ++++++++++++++ examples/grpc/helloworld/server/main.go | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/examples/grpc/helloworld/client/main.go b/examples/grpc/helloworld/client/main.go index 44693ddb..4ae141d3 100644 --- a/examples/grpc/helloworld/client/main.go +++ b/examples/grpc/helloworld/client/main.go @@ -1,3 +1,17 @@ +// Copyright 2025 The A2A Authors +// +// 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 +// +// http://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. + package main import ( diff --git a/examples/grpc/helloworld/server/main.go b/examples/grpc/helloworld/server/main.go index 38b8ef5a..f87790d8 100644 --- a/examples/grpc/helloworld/server/main.go +++ b/examples/grpc/helloworld/server/main.go @@ -1,3 +1,17 @@ +// Copyright 2025 The A2A Authors +// +// 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 +// +// http://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. + package main import ( From 1a8fc3d8ac9014f1ce058720fb1ccd77a1dc0498 Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Tue, 28 Oct 2025 08:33:46 +0000 Subject: [PATCH 04/15] update --- README.md | 8 +-- examples/grpc/helloworld/server/main.go | 75 +++++++++---------------- 2 files changed, 30 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 777fef68..08276774 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ ## ๐Ÿš€ Getting Started Requires Go `1.24.4` or newer: + ```bash go get github.com/a2aproject/a2a-go@v0.3.0 ``` @@ -49,16 +50,14 @@ For a simple example refer to [gRPC helloworld](./examples/grpc/helloworld). 2. Wrap the handler into a transport implementation: ```go - var cardProducer a2asrv.AgentCardProducer = newCustomCardProducer() - grpcHandler := a2agrpc.NewHandler(cardProducer, requestHandler) + grpcHandler := a2agrpc.NewHandler(requestHandler) ``` 3. Register handler with a server, for example: + ```go import "google.golang.org/grpc" - ... - server := grpc.NewServer() grpcHandler.RegisterWith(server) err := server.Serve(listener) @@ -81,6 +80,7 @@ For a simple example refer to [gRPC helloworld](./examples/grpc/helloworld). ``` 3. The connection is now open and can be used to send requests to a server: + ```go msg := a2a.NewMessage(a2a.MessageRoleUser, a2a.TextPart{Text: "..."}) resp, err := client.SendMessage(ctx, &a2a.MessageSendParams{Message: msg}) diff --git a/examples/grpc/helloworld/server/main.go b/examples/grpc/helloworld/server/main.go index f87790d8..9c97b8f7 100644 --- a/examples/grpc/helloworld/server/main.go +++ b/examples/grpc/helloworld/server/main.go @@ -16,7 +16,6 @@ package main import ( "context" - "encoding/json" "flag" "fmt" "log" @@ -44,34 +43,7 @@ func (*agentExecutor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestContext, return nil } -// agentCardProducer implements [a2asrv.AgentCardProducer], which is a required [a2agrpc.GRPCHandler] dependency. -// It is responsible for creating an a2a.AgentCard describing the agent and its capabilities. -type agentCardProducer struct { - grpcPort int -} - -func (p *agentCardProducer) Card() *a2a.AgentCard { - return &a2a.AgentCard{ - Name: "Hello World Agent", - Description: "Just a hello world agent", - URL: fmt.Sprintf("127.0.0.1:%d", p.grpcPort), - PreferredTransport: a2a.TransportProtocolGRPC, - DefaultInputModes: []string{"text"}, - DefaultOutputModes: []string{"text"}, - Capabilities: a2a.AgentCapabilities{Streaming: true}, - Skills: []a2a.AgentSkill{ - { - ID: "hello_world", - Name: "Hello, world!", - Description: "Returns a 'Hello, world!'", - Tags: []string{"hello world"}, - Examples: []string{"hi", "hello"}, - }, - }, - } -} - -func startGRPCServer(port int, card a2asrv.AgentCardProducer) error { +func startGRPCServer(port int, card *a2a.AgentCard) error { listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { return err @@ -81,17 +53,17 @@ func startGRPCServer(port int, card a2asrv.AgentCardProducer) error { // A transport-agnostic implementation of A2A protocol methods. // The behavior is configurable using option-arguments of form a2asrv.With*(), for example: // a2asrv.NewHandler(executor, a2asrv.WithTaskStore(customStore)) - requestHandler := a2asrv.NewHandler(&agentExecutor{}) + requestHandler := a2asrv.NewHandler(&agentExecutor{}, a2asrv.WithExtendedAgentCard(card)) // A gRPC-transport implementation for A2A. - grpcHandler := a2agrpc.NewHandler(card, requestHandler) + grpcHandler := a2agrpc.NewHandler(requestHandler) s := grpc.NewServer() grpcHandler.RegisterWith(s) return s.Serve(listener) } -func servePublicCard(port int, card a2asrv.AgentCardProducer) error { +func servePublicCard(port int, card *a2a.AgentCard) error { listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) if err != nil { return err @@ -100,20 +72,7 @@ func servePublicCard(port int, card a2asrv.AgentCardProducer) error { log.Printf("Starting a public AgentCard server on 127.0.0.1:%d", port) mux := http.NewServeMux() - mux.HandleFunc("/.well-known/agent-card.json", func(w http.ResponseWriter, r *http.Request) { - jsonData, err := json.Marshal(card.Card()) - if err != nil { - log.Printf("Error encoding JSON: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - if _, err := w.Write(jsonData); err != nil { - log.Printf("Error serving AgentCard: %v", err) - } else { - log.Print("AgentCard request handled") - } - }) + mux.Handle("/.well-known/agent-card.json", a2asrv.NewStaticAgentCardHandler(card)) return http.Serve(listener, mux) } @@ -124,14 +83,32 @@ var ( func main() { flag.Parse() - cardProducer := &agentCardProducer{grpcPort: *grpcPort} + + agentCard := &a2a.AgentCard{ + Name: "Hello World Agent", + Description: "Just a hello world agent", + URL: fmt.Sprintf("127.0.0.1:%d", *grpcPort), + PreferredTransport: a2a.TransportProtocolGRPC, + DefaultInputModes: []string{"text"}, + DefaultOutputModes: []string{"text"}, + Capabilities: a2a.AgentCapabilities{Streaming: true}, + Skills: []a2a.AgentSkill{ + { + ID: "hello_world", + Name: "Hello, world!", + Description: "Returns a 'Hello, world!'", + Tags: []string{"hello world"}, + Examples: []string{"hi", "hello"}, + }, + }, + } var group errgroup.Group group.Go(func() error { - return startGRPCServer(*grpcPort, cardProducer) + return startGRPCServer(*grpcPort, agentCard) }) group.Go(func() error { - return servePublicCard(*cardPort, cardProducer) + return servePublicCard(*cardPort, agentCard) }) if err := group.Wait(); err != nil { log.Fatalf("Server shutdown: %v", err) From be1aceb056f4b883f1066e30104c0919513a638f Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Fri, 31 Oct 2025 09:13:50 +0000 Subject: [PATCH 05/15] restructure helloworld example --- examples/{grpc => }/helloworld/client/main.go | 0 .../{grpc/helloworld/server => helloworld/server/grpc}/main.go | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename examples/{grpc => }/helloworld/client/main.go (100%) rename examples/{grpc/helloworld/server => helloworld/server/grpc}/main.go (100%) diff --git a/examples/grpc/helloworld/client/main.go b/examples/helloworld/client/main.go similarity index 100% rename from examples/grpc/helloworld/client/main.go rename to examples/helloworld/client/main.go diff --git a/examples/grpc/helloworld/server/main.go b/examples/helloworld/server/grpc/main.go similarity index 100% rename from examples/grpc/helloworld/server/main.go rename to examples/helloworld/server/grpc/main.go From 4560df23f7415ee6f520a7fc025c50a775b0aa2f Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Fri, 31 Oct 2025 11:15:02 +0000 Subject: [PATCH 06/15] a2aclient docs --- README.md | 14 ++++++-- a2a/agent.go | 17 ++++------ a2a/core.go | 6 ++-- a2a/doc.go | 6 ++-- a2aclient/agentcard/doc.go | 36 +++++++++++++++++++++ a2aclient/agentcard/resolver.go | 33 +++++++++++++------ a2aclient/agentcard/resolver_test.go | 18 +++++------ a2aclient/auth.go | 20 ++++++------ a2aclient/client.go | 8 ++--- a2aclient/doc.go | 43 +++++++++++++++++++++++++ a2aclient/errors.go | 21 ------------ a2aclient/factory.go | 35 ++++++++++---------- a2aclient/factory_test.go | 2 +- a2aclient/grpc.go | 3 +- a2aclient/jsonrpc.go | 47 ++++++++------------------- a2aclient/jsonrpc_test.go | 32 +++++++++---------- a2aclient/middleware.go | 17 ++++------ a2aclient/transport.go | 48 +++++++++++++++------------- examples/helloworld/client/main.go | 3 +- 19 files changed, 232 insertions(+), 177 deletions(-) create mode 100644 a2aclient/agentcard/doc.go create mode 100644 a2aclient/doc.go delete mode 100644 a2aclient/errors.go diff --git a/README.md b/README.md index 08276774..1e59733c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # A2A Go SDK [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) -[![Nightly Check](https://github.com/a2aproject/a2a-go/actions/workflows/nightly.yml/badge.svg)](https://github.com/a2aproject/a2a-go/actions/workflows/nightly.yml) +[![Nightly Check](https://github.com/a2aproject/a2a-go/actions/workflows/nightly.yaml/badge.svg)](https://github.com/a2aproject/a2a-go/actions/workflows/nightly.yaml) [![Go Doc](https://img.shields.io/badge/Go%20Package-Doc-blue.svg)](https://pkg.go.dev/github.com/a2aproject/a2a-go) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/a2aproject/a2a-go) @@ -33,12 +33,16 @@ Requires Go `1.24.4` or newer: go get github.com/a2aproject/a2a-go@v0.3.0 ``` +Visit [**pkg.go**](https://pkg.go.dev/github.com/a2aproject/a2a-go) for a full documentation. + ## Examples -For a simple example refer to [gRPC helloworld](./examples/grpc/helloworld). +For a simple example refer to [gRPC helloworld](./examples/grpc/helloworld). ### Server +For a full documentation visit [**pkg.go.dev/a2asrv**](https://pkg.go.dev/github.com/a2aproject/a2a-go/a2asrv). + 1. Create a transport-agnostic A2A request handler: ```go @@ -51,6 +55,10 @@ For a simple example refer to [gRPC helloworld](./examples/grpc/helloworld). ```go grpcHandler := a2agrpc.NewHandler(requestHandler) + + // or + + jsonrpcHandler := a2asrv.NewJSONRPCHandler(requestHandler) ``` 3. Register handler with a server, for example: @@ -65,6 +73,8 @@ For a simple example refer to [gRPC helloworld](./examples/grpc/helloworld). ### Client +For a full documentation visit [**pkg.go.dev/a2aclient**](https://pkg.go.dev/github.com/a2aproject/a2a-go/a2aclient). + 1. Resolve an `AgentCard` to get an information about how an agent is exposed. ```go diff --git a/a2a/agent.go b/a2a/agent.go index c67e60ef..60484160 100644 --- a/a2a/agent.go +++ b/a2a/agent.go @@ -19,14 +19,13 @@ type AgentCapabilities struct { // Extensions is a list of protocol extensions supported by the agent. Extensions []AgentExtension `json:"extensions,omitempty" yaml:"extensions,omitempty" mapstructure:"extensions,omitempty"` - // PushNotifications indicates if the agent supports sending push notifications - // for asynchronous task updates. + // PushNotifications indicates if the agent supports sending push notifications for asynchronous task updates. PushNotifications bool `json:"pushNotifications,omitempty" yaml:"pushNotifications,omitempty" mapstructure:"pushNotifications,omitempty"` // StateTransitionHistory indicates if the agent provides a history of state transitions for a task. StateTransitionHistory bool `json:"stateTransitionHistory,omitempty" yaml:"stateTransitionHistory,omitempty" mapstructure:"stateTransitionHistory,omitempty"` - // Streaming indicates if the agent supports Server-Sent Events (SSE) for streaming responses. + // Streaming indicates if the agent supports streaming responses. Streaming bool `json:"streaming,omitempty" yaml:"streaming,omitempty" mapstructure:"streaming,omitempty"` } @@ -54,8 +53,7 @@ type AgentCard struct { // - MAY reuse URLs if multiple transports are available at the same endpoint // - MUST accurately declare the transport available at each URL // - // Clients can select any interface from this list based on their transport - // capabilities + // Clients can select any interface from this list based on their transport capabilities // and preferences. This enables transport negotiation and fallback scenarios. AdditionalInterfaces []AgentInterface `json:"additionalInterfaces,omitempty" yaml:"additionalInterfaces,omitempty" mapstructure:"additionalInterfaces,omitempty"` @@ -87,10 +85,8 @@ type AgentCard struct { // If not specified, defaults to 'JSONRPC'. // // IMPORTANT: The transport specified here MUST be available at the main 'url'. - // This creates a binding between the main URL and its supported transport - // protocol. - // Clients should prefer this transport and URL combination when both are - // supported. + // This creates a binding between the main URL and its supported transport protocol. + // Clients should prefer this transport and URL combination when both are supported. PreferredTransport TransportProtocol `json:"preferredTransport,omitempty" yaml:"preferredTransport,omitempty" mapstructure:"preferredTransport,omitempty"` // ProtocolVersion is the version of the A2A protocol this agent supports. @@ -198,8 +194,7 @@ type AgentSkill struct { // ID is a unique identifier for the agent's skill. ID string `json:"id" yaml:"id" mapstructure:"id"` - // InputModes is the set of supported input MIME types for this skill, overriding the agent's - // defaults. + // InputModes is the set of supported input MIME types for this skill, overriding the agent's defaults. InputModes []string `json:"inputModes,omitempty" yaml:"inputModes,omitempty" mapstructure:"inputModes,omitempty"` // Name is a human-readable name for the skill. diff --git a/a2a/core.go b/a2a/core.go index f589c070..d0f7ca95 100644 --- a/a2a/core.go +++ b/a2a/core.go @@ -194,12 +194,12 @@ func (m *Message) TaskInfo() TaskInfo { // TaskID is a unique identifier for the task, generated by the server for a new task. type TaskID string -// NewTaskID creates a new random task identifier. +// NewTaskID generates a new random task identifier. func NewTaskID() TaskID { return TaskID(uuid.NewString()) } -// NewContextID creates a new random context identifier. +// NewContextID generates a new random context identifier. func NewContextID() string { return uuid.NewString() } @@ -315,7 +315,7 @@ func (m *Task) TaskInfo() TaskInfo { // ArtifactID is a unique identifier for the artifact within the scope of the task. type ArtifactID string -// NewArtifactID creates a new random artifact identifier. +// NewArtifactID generates a new random artifact identifier. func NewArtifactID() ArtifactID { return ArtifactID(uuid.NewString()) } diff --git a/a2a/doc.go b/a2a/doc.go index 5642398a..da6203e3 100644 --- a/a2a/doc.go +++ b/a2a/doc.go @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package a2a contains core types and constants from the A2A protocol -// shared by client and server implementations. -// +// Package a2a contains core types and constants from the A2A protocol shared by client and server implementations. // These types implement the A2A specification and are transport-agnostic. +// +// Additionally, the package provides factory methods for types implementing the [Event] interface. package a2a diff --git a/a2aclient/agentcard/doc.go b/a2aclient/agentcard/doc.go new file mode 100644 index 00000000..0b22d716 --- /dev/null +++ b/a2aclient/agentcard/doc.go @@ -0,0 +1,36 @@ +// Copyright 2025 The A2A Authors +// +// 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 +// +// http://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. + +/* +Package agentcard provides utilities for fetching public [a2a.AgentCard]. +A [Resolver] can be created with a custom [http.Client] or DefaultResolver can be used. + + card, err := agentcard.DefaultResolver.Resolve(ctx, baseURL) + + // or + + resolver := agentcard.NewResolver(customClient) + card, err := resolver.Resolve(ctx, baseURL) + +By default the request is sent for a well-known card location, but custom +this can be configured by providing [ResolveOption]s. + + card, err := resolver.Resolve( + ctx, + baseURL, + a2aclient.WithPath(customPath), + a2aclient.WithHeader(key, value), + ) +*/ +package agentcard diff --git a/a2aclient/agentcard/resolver.go b/a2aclient/agentcard/resolver.go index 0a0f7cfe..68999873 100644 --- a/a2aclient/agentcard/resolver.go +++ b/a2aclient/agentcard/resolver.go @@ -39,12 +39,23 @@ func (e *ErrStatusNotOK) Error() string { const defaultAgentCardPath = "/.well-known/agent-card.json" -// Resolver is used to fetch an AgentCard from the provided URL. +var defaultClient = &http.Client{Timeout: 15 * time.Second} + +// DefaultResolver is configured with an [http.Client] with a 15 seconds timeout. +var DefaultResolver = &Resolver{Client: defaultClient} + +// Resolver is used to fetch an [a2a.AgentCard]. type Resolver struct { - BaseURL string + // Client can be used to configure appropriate timeout, retry policy, and connection pooling + Client *http.Client +} + +// NewResolver is a [Resolver] constructor function. +func NewResolver(client *http.Client) *Resolver { + return &Resolver{Client: client} } -// ResolveOption is used to customize Resolve() behavior. +// ResolveOption is used to customize Resolve behavior. type ResolveOption func(r *resolveRequest) type resolveRequest struct { @@ -52,15 +63,15 @@ type resolveRequest struct { headers map[string]string } -// Resolve fetches an AgentCard from the provided URL. -// By default fetches from the /.well-known/agent-card.json path. -func (r *Resolver) Resolve(ctx context.Context, opts ...ResolveOption) (*a2a.AgentCard, error) { +// Resolve fetches an [a2a.AgentCard] from the provided base URL. +// By default the request is sent for the /.well-known/agent-card.json path. +func (r *Resolver) Resolve(ctx context.Context, baseURL string, opts ...ResolveOption) (*a2a.AgentCard, error) { reqSpec := &resolveRequest{path: defaultAgentCardPath, headers: make(map[string]string)} for _, o := range opts { o(reqSpec) } - reqUrl, err := url.JoinPath(r.BaseURL, reqSpec.path) + reqUrl, err := url.JoinPath(baseURL, reqSpec.path) if err != nil { return nil, fmt.Errorf("url construction failed: %w", err) } @@ -73,7 +84,11 @@ func (r *Resolver) Resolve(ctx context.Context, opts ...ResolveOption) (*a2a.Age req.Header.Add(h, val) } - client := &http.Client{Timeout: 30 * time.Second} + client := r.Client + if client == nil { + client = defaultClient + } + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("card request failed: %w", err) @@ -101,7 +116,7 @@ func (r *Resolver) Resolve(ctx context.Context, opts ...ResolveOption) (*a2a.Age return &card, nil } -// WithPath makes Resolve fetch from the provided path relative to BaseURL. +// WithPath makes Resolve fetch from the provided path relative to base URL. func WithPath(path string) ResolveOption { return func(r *resolveRequest) { r.path = path diff --git a/a2aclient/agentcard/resolver_test.go b/a2aclient/agentcard/resolver_test.go index 0b56f2d7..7da6c219 100644 --- a/a2aclient/agentcard/resolver_test.go +++ b/a2aclient/agentcard/resolver_test.go @@ -59,9 +59,9 @@ func mustServe(t *testing.T, path string, body []byte, callback func(r *http.Req func TestResolver_DefaultPath(t *testing.T) { want := &a2a.AgentCard{Name: "TestResolver_DefaultPath"} url := mustServe(t, defaultAgentCardPath, mustMarshal(t, want), nil) - resolver := Resolver{BaseURL: url} + resolver := Resolver{} - got, err := resolver.Resolve(t.Context()) + got, err := resolver.Resolve(t.Context(), url) if err != nil { t.Fatalf("Resolve() failed with: %v", err) } @@ -77,8 +77,8 @@ func TestResolver_CustomPath(t *testing.T) { want := &a2a.AgentCard{Name: "TestResolver_DefaultPath"} url := mustServe(t, path, mustMarshal(t, want), nil) - resolver := Resolver{BaseURL: url} - got, err := resolver.Resolve(ctx) + resolver := Resolver{} + got, err := resolver.Resolve(ctx, url) var httpErr *ErrStatusNotOK if err == nil || !errors.As(err, &httpErr) { t.Fatalf("expected Resolve() to fail with ErrStatusNotOK, got %v, %v", got, err) @@ -88,7 +88,7 @@ func TestResolver_CustomPath(t *testing.T) { } for _, p := range []string{path, strings.TrimPrefix(path, "/")} { - got, err = resolver.Resolve(ctx, WithPath(p)) + got, err = resolver.Resolve(ctx, url, WithPath(p)) if err != nil { t.Fatalf("Resolve(%s) failed with %v", p, err) } @@ -107,8 +107,8 @@ func TestResolver_CustomHeader(t *testing.T) { capturedHeader = req.Header[h] }) - resolver := Resolver{BaseURL: url} - _, err := resolver.Resolve(t.Context(), WithRequestHeader(h, hval)) + resolver := NewResolver(nil) + _, err := resolver.Resolve(t.Context(), url, WithRequestHeader(h, hval)) if err != nil { t.Fatalf("Resolve() failed with: %v", err) } @@ -121,8 +121,8 @@ func TestResolver_CustomHeader(t *testing.T) { func TestResolver_MalformedJSON(t *testing.T) { url := mustServe(t, defaultAgentCardPath, []byte(`}{`), nil) - resolver := Resolver{BaseURL: url} - got, err := resolver.Resolve(t.Context()) + resolver := NewResolver(nil) + got, err := resolver.Resolve(t.Context(), url) if err == nil { t.Fatalf("expected Resolve() to fail on malformed response, got: %v", got) } diff --git a/a2aclient/auth.go b/a2aclient/auth.go index e0edd1a5..3e1f97c7 100644 --- a/a2aclient/auth.go +++ b/a2aclient/auth.go @@ -22,7 +22,7 @@ import ( "github.com/a2aproject/a2a-go/a2a" ) -// ErrCredentialNotFound is returned by CredentialsService if a credential for the provided +// ErrCredentialNotFound is returned by [CredentialsService] if a credential for the provided // (sessionId, scheme) pair was not found. var ErrCredentialNotFound = errors.New("credential not found") @@ -33,7 +33,7 @@ type SessionID string type sessionIDKey struct{} // WithSessionID allows callers to attach a session identifier to the request. -// CallInterceptor can access this identifier through CallContext. +// [CallInterceptor] can access this identifier using [SessionIDFrom]. func WithSessionID(ctx context.Context, sid SessionID) context.Context { return context.WithValue(ctx, sessionIDKey{}, sid) } @@ -47,30 +47,30 @@ func SessionIDFrom(ctx context.Context) (SessionID, bool) { // AuthCredential represents a security-scheme specific credential (eg. a JWT token). type AuthCredential string -// AuthInterceptor implements CallInterceptor. -// It uses SessionID provided using a2aclient.WithSessionID to lookup credentials according -// and attach them to the according to the security scheme described in a2a.AgentCard. -// Credentials fetching is delegated to CredentialsService. +// AuthInterceptor implements [CallInterceptor]. +// It uses SessionID provided using [WithSessionID] to lookup credentials +// and attach them according to the security scheme specified in the agent card. +// Credentials fetching is delegated to [CredentialsService]. type AuthInterceptor struct { PassthroughInterceptor Service CredentialsService } -// CredentialsService is used by auth interceptor for resolving credentials. +// CredentialsService is used by [AuthInterceptor] for resolving credentials. type CredentialsService interface { Get(ctx context.Context, sid SessionID, scheme a2a.SecuritySchemeName) (AuthCredential, error) } -// SessionCredentials is a map of auth credentials by scheme name. +// SessionCredentials is a map of scheme names to auth credentials. type SessionCredentials map[a2a.SecuritySchemeName]AuthCredential -// InMemoryCredentialsStore implements CredentialsService. +// InMemoryCredentialsStore implements [CredentialsService]. type InMemoryCredentialsStore struct { mu sync.RWMutex credentials map[SessionID]SessionCredentials } -// NewInMemoryCredentialsStore initializes an InMemoryCredentialsStore. +// NewInMemoryCredentialsStore initializes an in-memory implementation of [CredentialsService]. func NewInMemoryCredentialsStore() InMemoryCredentialsStore { return InMemoryCredentialsStore{ credentials: make(map[SessionID]SessionCredentials), diff --git a/a2aclient/client.go b/a2aclient/client.go index 90cd1dc9..9d5078e8 100644 --- a/a2aclient/client.go +++ b/a2aclient/client.go @@ -22,7 +22,7 @@ import ( "github.com/a2aproject/a2a-go/a2a" ) -// Config exposes options for customizing Client behavior. +// Config exposes options for customizing [Client] behavior. type Config struct { // PushConfig specifies the default push notification configuration to apply for every Task. PushConfig *a2a.PushConfig @@ -41,8 +41,8 @@ type Config struct { } // Client represents a transport-agnostic implementation of A2A client. -// The actual call is delegated to a specific Transport implementation. -// CallInterceptors are applied before and after every protocol call. +// The actual call is delegated to a specific [Transport] implementation. +// [CallInterceptor]s are applied before and after every protocol call. type Client struct { config Config transport Transport @@ -52,7 +52,7 @@ type Client struct { card atomic.Pointer[a2a.AgentCard] } -// AddCallInterceptor allows to attach a CallInterceptor to the client after creation. +// AddCallInterceptor allows to attach a [CallInterceptor] to the client after creation. func (c *Client) AddCallInterceptor(ci CallInterceptor) { c.interceptors = append(c.interceptors, ci) } diff --git a/a2aclient/doc.go b/a2aclient/doc.go new file mode 100644 index 00000000..b223c284 --- /dev/null +++ b/a2aclient/doc.go @@ -0,0 +1,43 @@ +// Copyright 2025 The A2A Authors +// +// 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 +// +// http://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. + +/* +Package a2aclient provides a transport-agnostic A2A client implementation. Under the hood it handles +transport protocol negotiation and connection establishment. + +A [Client] can be configured with [CallInterceptor] middleware and custom +transports. + +If a client is created in multiple places, a [Factory] can be used to share the common configuration options: + + factory := NewFactory( + WithConfig(&a2aclient.Config{...}), + WithInterceptors(loggingInterceptor), + WithGRPCTransport(customGRPCOptions) + ) + +A client can be created from an [a2a.AgentCard] or a list of known [a2a.AgentInterface] descriptions +using either package-level functions or factory methods. + + client, err := factory.CreateFromEndpoints(ctx, []a2a.AgentInterface{URL: url, Transport: a2a.TransportProtocolGRPC}) + + // or + + card, err := agentcard.Fetch(ctx, url) + if err != nil { + log.Fatalf("Failed to resolve an AgentCard: %v", err) + } + client, err := a2aclient.NewFromCard(ctx, card, WithInterceptors(&customInterceptor{})) +*/ +package a2aclient diff --git a/a2aclient/errors.go b/a2aclient/errors.go deleted file mode 100644 index 0c289c25..00000000 --- a/a2aclient/errors.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2025 The A2A Authors -// -// 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 -// -// http://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. - -package a2aclient - -import "errors" - -// ErrNotImplemented is used during the API design stage. -// TODO(yarshevchuk): remove once Client and Transport implementations are in place. -var ErrNotImplemented = errors.New("not implemented") diff --git a/a2aclient/factory.go b/a2aclient/factory.go index be0a41eb..a97ef5d9 100644 --- a/a2aclient/factory.go +++ b/a2aclient/factory.go @@ -25,9 +25,8 @@ import ( "github.com/a2aproject/a2a-go/log" ) -// Factory provides an API for creating Clients compatible with the requested transports. -// Factory is immutable, but the configuration can be extended using WithAdditionalOptions(f, opts...) call. -// Additional configurations can be applied at the moment of Client creation. +// Factory provides an API for creating a [Client] compatible with requested transports. +// Factory is immutable, but the configuration can be extended using [WithAdditionalOptions] call. type Factory struct { config Config interceptors []CallInterceptor @@ -50,23 +49,23 @@ type transportCandidate struct { // // https://github.com/a2aproject/a2a-java (JSON-RPC included by default) // https://github.com/a2aproject/a2a-js (jsonrpc_transport_handler.ts) -var defaultOptions = []FactoryOption{WithJSONRPCTransport(), WithGRPCTransport()} +var defaultOptions = []FactoryOption{WithJSONRPCTransport(nil), WithGRPCTransport()} -// NewFromCard is a helper for creating a Client configured without creating a factory. -// It is equivalent to calling NewFromCard on a Factory created without any options. +// NewFromCard is a client [Client] constructor method which takes an [a2a.AgentCard] as input. +// It is equivalent to [Factory].CreateFromCard method. func NewFromCard(ctx context.Context, card *a2a.AgentCard, opts ...FactoryOption) (*Client, error) { return NewFactory(opts...).CreateFromCard(ctx, card) } -// NewFromEndpoints is a helper for creating a Client configured without creating a factory. -// It is equivalent to calling NewFromEndpoints on a Factory created without any options. +// NewFromEndpoints is a [Client] constructor method which takes known [a2a.AgentInterface] descriptions as input. +// It is equivalent to [Factory].CreateFromEndpoints method. func NewFromEndpoints(ctx context.Context, endpoints []a2a.AgentInterface, opts ...FactoryOption) (*Client, error) { return NewFactory(opts...).CreateFromEndpoints(ctx, endpoints) } -// CreateFromCard returns a Client configured to communicate with the agent described by -// the provided AgentCard or fails if we couldn't establish a compatible transport. -// Config PreferredTransports will be used to determine the order of connection attempts. +// CreateFromCard returns a [Client] configured to communicate with the agent described by +// the provided [a2a.AgentCard] or fails if we couldn't establish a compatible transport. +// [Config].PreferredTransports field is used to determine the order of connection attempts. // If PreferredTransports were not provided, we start from the PreferredTransport specified in the AgentCard // and proceed in the order specified by the AdditionalInterfaces. // The method fails if we couldn't establish a compatible transport. @@ -95,9 +94,9 @@ func (f *Factory) CreateFromCard(ctx context.Context, card *a2a.AgentCard) (*Cli return client, nil } -// CreateFromEndpoints returns a Client configured to communicate with one of the provided endpoints. -// Config PreferredTransports will be used to determine the order of connection attempts. -// If PreferredTransports were not provided, we attempt to establish using the provided endpoint order. +// CreateFromEndpoints returns a [Client] configured to communicate with one of the provided endpoints. +// [Config].PreferredTransports field is used to determine the order of connection attempts. +// If PreferredTransports were not provided, we attempt to establish a connection using the provided endpoint order. // The method fails if we couldn't establish a compatible transport. func (f *Factory) CreateFromEndpoints(ctx context.Context, endpoints []a2a.AgentInterface) (*Client, error) { candidates, err := f.selectTransport(endpoints) @@ -182,7 +181,7 @@ func (f *Factory) selectTransport(available []a2a.AgentInterface) ([]transportCa return candidates, nil } -// FactoryOption represents a configuration applied to a Factory. +// FactoryOption represents a configuration for creating a [Client]. type FactoryOption interface { apply(f *Factory) } @@ -193,21 +192,21 @@ func (f factoryOptionFn) apply(factory *Factory) { f(factory) } -// WithConfig makes the provided Config be used for all Clients created by the factory. +// WithConfig configures [Client] with the provided [Config]. func WithConfig(c Config) FactoryOption { return factoryOptionFn(func(f *Factory) { f.config = c }) } -// WithTransport enables the factory to creates clients for the provided protocol. +// WithTransport uses the provided factory during connection establishment for the specified protocol. func WithTransport(protocol a2a.TransportProtocol, factory TransportFactory) FactoryOption { return factoryOptionFn(func(f *Factory) { f.transports[protocol] = factory }) } -// WithInterceptors attaches call interceptors to clients created by the factory. +// WithInterceptors attaches call interceptors to created [Client]s. func WithInterceptors(interceptors ...CallInterceptor) FactoryOption { return factoryOptionFn(func(f *Factory) { f.interceptors = append(f.interceptors, interceptors...) diff --git a/a2aclient/factory_test.go b/a2aclient/factory_test.go index ba8743f6..66b13f43 100644 --- a/a2aclient/factory_test.go +++ b/a2aclient/factory_test.go @@ -154,7 +154,7 @@ func TestFactory_TransportSelection(t *testing.T) { return nil, fmt.Errorf("connection failed") } selectedProtocol = protocol - return UnimplementedTransport{}, nil + return unimplementedTransport{}, nil })) } if tc.clientPrefers != nil { diff --git a/a2aclient/grpc.go b/a2aclient/grpc.go index f60ec4a7..ef6a2c4f 100644 --- a/a2aclient/grpc.go +++ b/a2aclient/grpc.go @@ -26,8 +26,7 @@ import ( "github.com/a2aproject/a2a-go/a2apb/pbconv" ) -// WithGRPCTransport returns a Client factory configuration option that if applied will -// enable support of gRPC-A2A communication. +// WithGRPCTransport create a gRPC transport implementation which will use the provided [grpc.DialOption]s during connection establishment. func WithGRPCTransport(opts ...grpc.DialOption) FactoryOption { return WithTransport( a2a.TransportProtocolGRPC, diff --git a/a2aclient/jsonrpc.go b/a2aclient/jsonrpc.go index fc0e13dc..13cc9805 100644 --- a/a2aclient/jsonrpc.go +++ b/a2aclient/jsonrpc.go @@ -58,54 +58,33 @@ const ( // Options are applied during NewJSONRPCTransport initialization. type JSONRPCOption func(*jsonrpcTransport) -// WithHTTPClient sets a custom HTTP client for the JSONRPC transport. -// By default, a client with 5-second timeout is used (matching the Python SDK default). -// For production deployments, provide a client with appropriate timeout, retry policy, -// and connection pooling configured for your requirements. -// -// Example: -// -// client := &http.Client{ -// Timeout: 60 * time.Second, -// Transport: &http.Transport{ -// MaxIdleConns: 100, -// MaxIdleConnsPerHost: 10, -// IdleConnTimeout: 90 * time.Second, -// }, -// } -// transport := NewJSONRPCTransport(url, card, WithHTTPClient(client)) -func WithHTTPClient(client *http.Client) JSONRPCOption { - return func(t *jsonrpcTransport) { - t.httpClient = client - } -} - // WithJSONRPCTransport returns a Client factory option that enables JSON-RPC transport support. // When applied, the client will use JSON-RPC 2.0 over HTTP for all A2A protocol communication // as defined in the A2A specification ยง7. -func WithJSONRPCTransport(opts ...JSONRPCOption) FactoryOption { +func WithJSONRPCTransport(client *http.Client) FactoryOption { return WithTransport( a2a.TransportProtocolJSONRPC, TransportFactoryFn(func(ctx context.Context, url string, card *a2a.AgentCard) (Transport, error) { - return NewJSONRPCTransport(url, card, opts...), nil + return NewJSONRPCTransport(url, card, client), nil }), ) } // NewJSONRPCTransport creates a new JSON-RPC transport for A2A protocol communication. -// By default, an HTTP client with 5-second timeout is used (matching Python SDK behavior). -// For custom timeout, retry logic, or connection pooling, provide a configured client via WithHTTPClient. -func NewJSONRPCTransport(url string, card *a2a.AgentCard, opts ...JSONRPCOption) Transport { +// By default, an HTTP client with 5-second timeout is used. +// For production deployments, provide a client with appropriate timeout, retry policy, +// and connection pooling configured for your requirements. +func NewJSONRPCTransport(url string, card *a2a.AgentCard, client *http.Client) Transport { t := &jsonrpcTransport{ - url: url, - agentCard: card, - httpClient: &http.Client{ - Timeout: 5 * time.Second, // Match Python SDK httpx.AsyncClient default - }, + url: url, + agentCard: card, + httpClient: client, } - for _, opt := range opts { - opt(t) + if t.httpClient == nil { + t.httpClient = &http.Client{ + Timeout: 5 * time.Second, // Match Python SDK httpx.AsyncClient default + } } return t diff --git a/a2aclient/jsonrpc_test.go b/a2aclient/jsonrpc_test.go index e6b7e6d7..cf63cff1 100644 --- a/a2aclient/jsonrpc_test.go +++ b/a2aclient/jsonrpc_test.go @@ -62,7 +62,7 @@ func TestJSONRPCTransport_SendMessage(t *testing.T) { defer server.Close() // Create transport - transport := NewJSONRPCTransport(server.URL, nil) + transport := NewJSONRPCTransport(server.URL, nil, nil) // Send message result, err := transport.SendMessage(context.Background(), &a2a.MessageSendParams{ @@ -105,7 +105,7 @@ func TestJSONRPCTransport_SendMessage_MessageResult(t *testing.T) { })) defer server.Close() - transport := NewJSONRPCTransport(server.URL, nil) + transport := NewJSONRPCTransport(server.URL, nil, nil) result, err := transport.SendMessage(context.Background(), &a2a.MessageSendParams{ Message: a2a.NewMessage(a2a.MessageRoleUser, &a2a.TextPart{Text: "test message"}), @@ -150,7 +150,7 @@ func TestJSONRPCTransport_GetTask(t *testing.T) { })) defer server.Close() - transport := NewJSONRPCTransport(server.URL, nil) + transport := NewJSONRPCTransport(server.URL, nil, nil) task, err := transport.GetTask(context.Background(), &a2a.TaskQueryParams{ ID: "task-123", @@ -188,7 +188,7 @@ func TestJSONRPCTransport_ErrorHandling(t *testing.T) { })) defer server.Close() - transport := NewJSONRPCTransport(server.URL, nil) + transport := NewJSONRPCTransport(server.URL, nil, nil) _, err := transport.GetTask(context.Background(), &a2a.TaskQueryParams{ ID: "task-123", @@ -235,7 +235,7 @@ func TestJSONRPCTransport_SendStreamingMessage(t *testing.T) { })) defer server.Close() - transport := NewJSONRPCTransport(server.URL, nil) + transport := NewJSONRPCTransport(server.URL, nil, nil) events := []a2a.Event{} for event, err := range transport.SendStreamingMessage(context.Background(), &a2a.MessageSendParams{ @@ -324,7 +324,7 @@ func TestJSONRPCTransport_ResubscribeToTask(t *testing.T) { })) defer server.Close() - transport := NewJSONRPCTransport(server.URL, nil) + transport := NewJSONRPCTransport(server.URL, nil, nil) events := []a2a.Event{} for event, err := range transport.ResubscribeToTask(context.Background(), &a2a.TaskIDParams{ @@ -359,7 +359,7 @@ func TestJSONRPCTransport_GetAgentCard(t *testing.T) { SupportsAuthenticatedExtendedCard: false, } - transport := NewJSONRPCTransport("http://example.com", card) + transport := NewJSONRPCTransport("http://example.com", card, nil) result, err := transport.GetAgentCard(context.Background()) if err != nil { @@ -378,7 +378,7 @@ func TestJSONRPCTransport_GetAgentCard(t *testing.T) { Description: "Test description", } - transport := NewJSONRPCTransport("http://example.com", card) + transport := NewJSONRPCTransport("http://example.com", card, nil) result, err := transport.GetAgentCard(context.Background()) if err != nil { @@ -395,7 +395,7 @@ func TestJSONRPCTransport_GetAgentCard(t *testing.T) { }) t.Run("no card provided", func(t *testing.T) { - transport := NewJSONRPCTransport("http://example.com", nil) + transport := NewJSONRPCTransport("http://example.com", nil, nil) _, err := transport.GetAgentCard(context.Background()) if err == nil { @@ -425,7 +425,7 @@ func TestJSONRPCTransport_CancelTask(t *testing.T) { })) defer server.Close() - transport := NewJSONRPCTransport(server.URL, nil) + transport := NewJSONRPCTransport(server.URL, nil, nil) task, err := transport.CancelTask(context.Background(), &a2a.TaskIDParams{ ID: "task-123", @@ -461,7 +461,7 @@ func TestJSONRPCTransport_PushNotificationConfig(t *testing.T) { })) defer server.Close() - transport := NewJSONRPCTransport(server.URL, nil) + transport := NewJSONRPCTransport(server.URL, nil, nil) config, err := transport.GetTaskPushConfig(context.Background(), &a2a.GetTaskPushConfigParams{ TaskID: "task-123", @@ -496,7 +496,7 @@ func TestJSONRPCTransport_PushNotificationConfig(t *testing.T) { })) defer server.Close() - transport := NewJSONRPCTransport(server.URL, nil) + transport := NewJSONRPCTransport(server.URL, nil, nil) configs, err := transport.ListTaskPushConfig(context.Background(), &a2a.ListTaskPushConfigParams{}) @@ -529,7 +529,7 @@ func TestJSONRPCTransport_PushNotificationConfig(t *testing.T) { })) defer server.Close() - transport := NewJSONRPCTransport(server.URL, nil) + transport := NewJSONRPCTransport(server.URL, nil, nil) config, err := transport.SetTaskPushConfig(context.Background(), &a2a.TaskPushConfig{ TaskID: "task-123", @@ -572,7 +572,7 @@ func TestJSONRPCTransport_PushNotificationConfig(t *testing.T) { })) defer server.Close() - transport := NewJSONRPCTransport(server.URL, nil) + transport := NewJSONRPCTransport(server.URL, nil, nil) err := transport.DeleteTaskPushConfig(context.Background(), &a2a.DeleteTaskPushConfigParams{ TaskID: "task-123", @@ -586,7 +586,7 @@ func TestJSONRPCTransport_PushNotificationConfig(t *testing.T) { func TestJSONRPCTransport_DefaultTimeout(t *testing.T) { // Test that default transport has 5-second timeout (matching Python SDK) - transport := NewJSONRPCTransport("http://example.com", nil) + transport := NewJSONRPCTransport("http://example.com", nil, nil) // Access internal transport to check HTTP client timeout jt := transport.(*jsonrpcTransport) @@ -618,7 +618,7 @@ func TestJSONRPCTransport_WithHTTPClient(t *testing.T) { })) defer server.Close() - transport := NewJSONRPCTransport(server.URL, nil, WithHTTPClient(customClient)) + transport := NewJSONRPCTransport(server.URL, nil, customClient) // Verify custom client is used jt := transport.(*jsonrpcTransport) diff --git a/a2aclient/middleware.go b/a2aclient/middleware.go index 56b288e7..09c29c33 100644 --- a/a2aclient/middleware.go +++ b/a2aclient/middleware.go @@ -23,14 +23,13 @@ import ( // Used to store CallMeta in context.Context after all the interceptors were applied. type callMetaKey struct{} -// CallMeta holds things like auth headers, signatures etc. -// In jsonrpc it is passed as HTTP headers, in gRPC it becomes a part of context.Context. -// Custom protocol implementations can use CallMetaFrom to access this data and +// CallMeta holds things like auth headers and signatures. +// In jsonrpc it is passed as HTTP headers, in gRPC it becomes a part of [context.Context]. +// Custom protocol implementations can use [CallMetaFrom] to access this data and // perform the operations necessary for attaching it to the request. type CallMeta map[string][]string // Request represents a transport-agnostic request to be sent to A2A server. -// Payload is one of a2a package core types. type Request struct { // Method is the name of the method invoked on the A2A-server. Method string @@ -41,12 +40,11 @@ type Request struct { // Card is the AgentCard of the agent the client is connected to. Might be nil if Client was // created directly from server URL and extended AgentCard was never fetched. Card *a2a.AgentCard - // Payload is the request payload. It is nil if the method does not take any parameters. + // Payload is the request payload. It is nil if the method does not take any parameters. Otherwise, it is one of a2a package core types otherwise. Payload any } // Response represents a transport-agnostic result received from A2A server. -// Payload is one of a2a package core types. type Response struct { // Method is the name of the method invoked on the A2A-server. Method string @@ -59,11 +57,11 @@ type Response struct { // Card is the AgentCard of the agent the client is connected to. Might be nil if Client was // created directly from server URL and extended AgentCard was never fetched. Card *a2a.AgentCard - // Payload is the response. It is nil if method doesn't return anything or Err was returned. + // Payload is the response. It is nil if method doesn't return anything or Err was returned. Otherwise, it is one of a2a package core types otherwise. Payload any } -// CallInterceptor can be attached to an a2aclient.Client. +// CallInterceptor can be attached to an [Client]. // If multiple interceptors are added: // - Before will be executed in the order of attachment sequentially. // - After will be executed in the reverse order sequentially. @@ -76,8 +74,7 @@ type CallInterceptor interface { After(ctx context.Context, resp *Response) error } -// CallMetaFrom allows Transport implementations to access CallMeta after all -// the interceptors were applied. +// CallMetaFrom allows [Transport] implementations to access CallMeta after all the interceptors were applied. func CallMetaFrom(ctx context.Context) (CallMeta, bool) { meta, ok := ctx.Value(callMetaKey{}).(CallMeta) return meta, ok diff --git a/a2aclient/transport.go b/a2aclient/transport.go index c4944816..41b69912 100644 --- a/a2aclient/transport.go +++ b/a2aclient/transport.go @@ -16,12 +16,14 @@ package a2aclient import ( "context" + "errors" "iter" "github.com/a2aproject/a2a-go/a2a" ) // A2AClient defines a transport-agnostic interface for making A2A requests. +// Transport implementations are a translation layer between a2a core types and wire formats. type Transport interface { // GetTask calls the 'tasks/get' protocol method. GetTask(ctx context.Context, query *a2a.TaskQueryParams) (*a2a.Task, error) @@ -70,52 +72,54 @@ func (fn TransportFactoryFn) Create(ctx context.Context, url string, card *a2a.A return fn(ctx, url, card) } -type UnimplementedTransport struct{} +var errNotImplemented = errors.New("not implemented") -func (UnimplementedTransport) GetTask(ctx context.Context, query *a2a.TaskQueryParams) (*a2a.Task, error) { - return nil, ErrNotImplemented +type unimplementedTransport struct{} + +func (unimplementedTransport) GetTask(ctx context.Context, query *a2a.TaskQueryParams) (*a2a.Task, error) { + return nil, errNotImplemented } -func (UnimplementedTransport) CancelTask(ctx context.Context, id *a2a.TaskIDParams) (*a2a.Task, error) { - return nil, ErrNotImplemented +func (unimplementedTransport) CancelTask(ctx context.Context, id *a2a.TaskIDParams) (*a2a.Task, error) { + return nil, errNotImplemented } -func (UnimplementedTransport) SendMessage(ctx context.Context, message *a2a.MessageSendParams) (a2a.SendMessageResult, error) { - return nil, ErrNotImplemented +func (unimplementedTransport) SendMessage(ctx context.Context, message *a2a.MessageSendParams) (a2a.SendMessageResult, error) { + return nil, errNotImplemented } -func (UnimplementedTransport) ResubscribeToTask(ctx context.Context, id *a2a.TaskIDParams) iter.Seq2[a2a.Event, error] { +func (unimplementedTransport) ResubscribeToTask(ctx context.Context, id *a2a.TaskIDParams) iter.Seq2[a2a.Event, error] { return func(yield func(a2a.Event, error) bool) { - yield(nil, ErrNotImplemented) + yield(nil, errNotImplemented) } } -func (UnimplementedTransport) SendStreamingMessage(ctx context.Context, message *a2a.MessageSendParams) iter.Seq2[a2a.Event, error] { +func (unimplementedTransport) SendStreamingMessage(ctx context.Context, message *a2a.MessageSendParams) iter.Seq2[a2a.Event, error] { return func(yield func(a2a.Event, error) bool) { - yield(nil, ErrNotImplemented) + yield(nil, errNotImplemented) } } -func (UnimplementedTransport) GetTaskPushConfig(ctx context.Context, params *a2a.GetTaskPushConfigParams) (*a2a.TaskPushConfig, error) { - return nil, ErrNotImplemented +func (unimplementedTransport) GetTaskPushConfig(ctx context.Context, params *a2a.GetTaskPushConfigParams) (*a2a.TaskPushConfig, error) { + return nil, errNotImplemented } -func (UnimplementedTransport) ListTaskPushConfig(ctx context.Context, params *a2a.ListTaskPushConfigParams) ([]*a2a.TaskPushConfig, error) { - return nil, ErrNotImplemented +func (unimplementedTransport) ListTaskPushConfig(ctx context.Context, params *a2a.ListTaskPushConfigParams) ([]*a2a.TaskPushConfig, error) { + return nil, errNotImplemented } -func (UnimplementedTransport) SetTaskPushConfig(ctx context.Context, params *a2a.TaskPushConfig) (*a2a.TaskPushConfig, error) { - return nil, ErrNotImplemented +func (unimplementedTransport) SetTaskPushConfig(ctx context.Context, params *a2a.TaskPushConfig) (*a2a.TaskPushConfig, error) { + return nil, errNotImplemented } -func (UnimplementedTransport) DeleteTaskPushConfig(ctx context.Context, params *a2a.DeleteTaskPushConfigParams) error { - return ErrNotImplemented +func (unimplementedTransport) DeleteTaskPushConfig(ctx context.Context, params *a2a.DeleteTaskPushConfigParams) error { + return errNotImplemented } -func (UnimplementedTransport) GetAgentCard(ctx context.Context) (*a2a.AgentCard, error) { - return nil, ErrNotImplemented +func (unimplementedTransport) GetAgentCard(ctx context.Context) (*a2a.AgentCard, error) { + return nil, errNotImplemented } -func (UnimplementedTransport) Destroy() error { +func (unimplementedTransport) Destroy() error { return nil } diff --git a/examples/helloworld/client/main.go b/examples/helloworld/client/main.go index 4ae141d3..4d429ff6 100644 --- a/examples/helloworld/client/main.go +++ b/examples/helloworld/client/main.go @@ -34,8 +34,7 @@ func main() { ctx := context.Background() // Resolve an AgentCard - cardResolver := agentcard.Resolver{BaseURL: *cardURL} - card, err := cardResolver.Resolve(ctx) + card, err := agentcard.DefaultResolver.Resolve(ctx, *cardURL) if err != nil { log.Fatalf("Failed to resolve an AgentCard: %v", err) } From 79f50c105389b469fbd0dfcc7841b72268504ff5 Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Fri, 31 Oct 2025 11:25:56 +0000 Subject: [PATCH 07/15] a2agrpc package doc --- a2aclient/agentcard/doc.go | 2 +- a2agrpc/doc.go | 14 +++++++++++++- a2agrpc/handler.go | 3 +++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/a2aclient/agentcard/doc.go b/a2aclient/agentcard/doc.go index 0b22d716..8cbaad39 100644 --- a/a2aclient/agentcard/doc.go +++ b/a2aclient/agentcard/doc.go @@ -14,7 +14,7 @@ /* Package agentcard provides utilities for fetching public [a2a.AgentCard]. -A [Resolver] can be created with a custom [http.Client] or DefaultResolver can be used. +A [Resolver] can be created with a custom [http.Client] or package-level DefaultResolver can be used. card, err := agentcard.DefaultResolver.Resolve(ctx, baseURL) diff --git a/a2agrpc/doc.go b/a2agrpc/doc.go index 35aef401..bc09dce9 100644 --- a/a2agrpc/doc.go +++ b/a2agrpc/doc.go @@ -12,5 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package a2agrpc provides a gRPC transport implementation for an A2A server. +/* +Package a2agrpc provides an A2A gRPC service implementation which can be registered with [grpc.Server]. +The implementation performs protobuf translation and delegates the actual method handling to +a transport-agnostic [a2asrv.RequestHandler]. + + grpcHandler := a2agrpc.NewHandler(requestHandler) + + s := grpc.NewServer() + grpcHandler.RegisterWith(s) + if err := s.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) { + log.Fatalf("Server exited with error: %v", err) + } +*/ package a2agrpc diff --git a/a2agrpc/handler.go b/a2agrpc/handler.go index 5e3daf08..06a2a287 100644 --- a/a2agrpc/handler.go +++ b/a2agrpc/handler.go @@ -30,15 +30,18 @@ import ( "github.com/a2aproject/a2a-go/a2asrv" ) +// Handler implements protobuf translation layer and delegates the actual method handling to [a2asrv.RequestHandler]. type Handler struct { a2apb.UnimplementedA2AServiceServer handler a2asrv.RequestHandler } +// RegisterWith registers as an A2AService implementation with the provided [grpc.Server]. func (h *Handler) RegisterWith(s *grpc.Server) { a2apb.RegisterA2AServiceServer(s, h) } +// NewHandler is a [Handler] constructor function. func NewHandler(handler a2asrv.RequestHandler) *Handler { return &Handler{handler: handler} } From c5009c79ed3863705cae23e6e4d900699307bc85 Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Fri, 31 Oct 2025 12:14:07 +0000 Subject: [PATCH 08/15] log package doc --- log/logger.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/log/logger.go b/log/logger.go index c98e5459..1d56ea96 100644 --- a/log/logger.go +++ b/log/logger.go @@ -12,6 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Package log provides utilities for attaching an [slog.Logger] configured with request-specific +// attributes to [context.Context]. The logger can later be retrieved or used indirectly through package-level +// logging function calls. +// +// Server and client extension point implementations should use this package for generating logs +// instead of using other loggers or slog directly. package log import ( From 4fc10b119c216d952db7a7bfb31452e9660ca932 Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Fri, 31 Oct 2025 12:32:32 +0000 Subject: [PATCH 09/15] internal package doc updates --- internal/taskexec/doc.go | 2 +- internal/taskexec/execution.go | 2 +- internal/taskexec/manager.go | 18 ++++++++++-------- internal/taskexec/subscription.go | 2 +- internal/taskstore/doc.go | 2 +- internal/taskstore/store.go | 4 ++-- internal/taskupdate/doc.go | 3 +-- internal/taskupdate/manager.go | 10 +++++----- 8 files changed, 22 insertions(+), 21 deletions(-) diff --git a/internal/taskexec/doc.go b/internal/taskexec/doc.go index 91d6b746..bc0db69e 100644 --- a/internal/taskexec/doc.go +++ b/internal/taskexec/doc.go @@ -16,7 +16,7 @@ // The manager enforces concurrency control on task level and guarantees that at any given moment // there's only one goroutine which is mutating the state of an [a2a.Task]. // -// For every [Execution] the [Manager] start two goroutines in an [errgroup.Group]: +// For every [Execution] the [Manager] starts two goroutines in an [errgroup.Group]: // - One calls [Executor] and starts producing events writing them to an [eventqueue.Queue]. // - The second one reads events in a loop and passes them through [Processor] responsible for deciding when to stop. // diff --git a/internal/taskexec/execution.go b/internal/taskexec/execution.go index 22deb0e4..64cd4bb2 100644 --- a/internal/taskexec/execution.go +++ b/internal/taskexec/execution.go @@ -50,7 +50,7 @@ func newExecution(tid a2a.TaskID, controller Executor) *Execution { } } -// Events subscribes to the events the agent is producing during an active Execution. +// Events subscribes to the events an agent is producing during an active Execution. // If the Execution was finished the sequence will be empty. func (e *Execution) Events(ctx context.Context) iter.Seq2[a2a.Event, error] { return func(yield func(a2a.Event, error) bool) { diff --git a/internal/taskexec/manager.go b/internal/taskexec/manager.go index e2e6e76c..7a9f912e 100644 --- a/internal/taskexec/manager.go +++ b/internal/taskexec/manager.go @@ -51,7 +51,7 @@ type Manager struct { cancelations map[a2a.TaskID]*cancelation } -// NewManager creates an initialized Manager instance. +// NewManager is a [Manager] constructor function. func NewManager(queueManager eventqueue.Manager) *Manager { return &Manager{ queueManager: queueManager, @@ -60,7 +60,8 @@ func NewManager(queueManager eventqueue.Manager) *Manager { } } -// GetExecution can be used to resubscribe to events which are being produced by agentExecution. +// GetExecution is used to get a reference to an active [Execution]. The method can be used +// to resubscribe to execution events or wait for its completion. func (m *Manager) GetExecution(taskID a2a.TaskID) (*Execution, bool) { m.mu.Lock() defer m.mu.Unlock() @@ -68,7 +69,8 @@ func (m *Manager) GetExecution(taskID a2a.TaskID) (*Execution, bool) { return execution, ok } -// Execute starts an AgentExecutor in a separate goroutine with a detached context. +// Execute starts two goroutine in a detached context. One will invoke [Executor] for event generation and +// the other one will be processing events passed through an [eventqueue.Queue]. // There can only be a single active execution per TaskID. func (m *Manager) Execute(ctx context.Context, tid a2a.TaskID, executor Executor) (*Execution, *Subscription, error) { m.mu.Lock() @@ -95,11 +97,11 @@ func (m *Manager) Execute(ctx context.Context, tid a2a.TaskID, executor Executor return execution, subscription, nil } -// Cancel uses Canceler to finish execution and waits for it to finish. -// If there's a cancelation in progress we wait for its result instead of starting a new attempt. -// If there's an active Execution Canceler will be writing to the same result queue. Consumers -// subscribed to the Execution will receive a Task cancelation Event. -// If there's no active Execution Canceler is responsible for processing Task events. +// Cancel uses [Canceler] to signal task cancelation and waits for it to take effect. +// If there's a cancelation in progress we wait for its result instead of starting a new one. +// If there's an active [Execution] Canceler will be writing to the same result queue. Consumers +// subscribed to the Execution will receive a task cancelation event and handle it accordingly. +// If there's no active Execution Canceler will be processing task events. func (m *Manager) Cancel(ctx context.Context, tid a2a.TaskID, canceler Canceler) (*a2a.Task, error) { m.mu.Lock() execution := m.executions[tid] diff --git a/internal/taskexec/subscription.go b/internal/taskexec/subscription.go index c5649172..f30409b2 100644 --- a/internal/taskexec/subscription.go +++ b/internal/taskexec/subscription.go @@ -22,7 +22,7 @@ import ( "github.com/a2aproject/a2a-go/a2a" ) -// Subscription encapsulates the logic of subscribing a channel to Execution events and canceling the subscription. +// Subscription encapsulates the logic of subscribing a channel to [Execution] events and canceling the subscription. // A default subscription is created when an Execution is started. type Subscription struct { eventsChan chan a2a.Event diff --git a/internal/taskstore/doc.go b/internal/taskstore/doc.go index 0d0adc0c..1adf7954 100644 --- a/internal/taskstore/doc.go +++ b/internal/taskstore/doc.go @@ -12,5 +12,5 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package taskstore provides types and utilities for storing Task snapshots. +// Package taskstore provides types and utilities for storing [a2a.Task] snapshots. package taskstore diff --git a/internal/taskstore/store.go b/internal/taskstore/store.go index ae7c2f0e..3c27c1b3 100644 --- a/internal/taskstore/store.go +++ b/internal/taskstore/store.go @@ -23,7 +23,7 @@ import ( "github.com/a2aproject/a2a-go/internal/utils" ) -// Mem stores deep-copied Tasks in memory. +// Mem stores deep-copied [a2a.Task]-s in memory. type Mem struct { mu sync.RWMutex tasks map[a2a.TaskID]*a2a.Task @@ -34,7 +34,7 @@ func init() { gob.Register([]any{}) } -// NewMem creates an empty Mem store. +// NewMem creates an empty [Mem] store. func NewMem() *Mem { return &Mem{ tasks: make(map[a2a.TaskID]*a2a.Task), diff --git a/internal/taskupdate/doc.go b/internal/taskupdate/doc.go index 27622d1d..4382f0c2 100644 --- a/internal/taskupdate/doc.go +++ b/internal/taskupdate/doc.go @@ -12,6 +12,5 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package taskupdate provides types and utilities for performing Task updates in response -// to events produced by the AgentExecutor. +// Package taskupdate provides types and utilities for performing [a2a.Task] updates in response to [a2a.Event]-s. package taskupdate diff --git a/internal/taskupdate/manager.go b/internal/taskupdate/manager.go index 67e13de8..f4e9b89c 100644 --- a/internal/taskupdate/manager.go +++ b/internal/taskupdate/manager.go @@ -24,24 +24,24 @@ import ( "github.com/a2aproject/a2a-go/log" ) -// Saver is used for saving the Task after updating its state. +// Saver is used for saving the [a2a.Task] after updating its state. type Saver interface { Save(ctx context.Context, task *a2a.Task) error } -// Manager is used for processing a2a.Event related to a Task. It updates -// the Task accordingly and uses Saver to store the new state. +// Manager is used for processing [a2a.Event] related to an [a2a.Task]. It updates +// the Task accordingly and uses [Saver] to store the new state. type Manager struct { task *a2a.Task saver Saver } -// NewManager creates an initialized update Manager for the provided task. +// NewManager is a [Manager] constructor function. func NewManager(saver Saver, task *a2a.Task) *Manager { return &Manager{task: task, saver: saver} } -// Process validates that the event is associated with the managed Task and updates the Task accordingly. +// Process validates the event associated with the managed [a2a.Task] and integrates the new state into it. func (mgr *Manager) Process(ctx context.Context, event a2a.Event) (*a2a.Task, error) { if mgr.task == nil { return nil, fmt.Errorf("event processor Task not set") From 9195699f45b18bb457283ba9c14753bc0a2c0488 Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Fri, 31 Oct 2025 13:49:43 +0000 Subject: [PATCH 10/15] a2asrv doc --- a2asrv/agentcard.go | 4 +- a2asrv/agentexec.go | 74 ++++++++++++++++++++++++++++++----- a2asrv/auth.go | 4 +- a2asrv/doc.go | 39 +++++++++++++++--- a2asrv/extensions.go | 5 ++- a2asrv/handler.go | 21 +++++----- a2asrv/intercepted_handler.go | 4 +- a2asrv/middleware.go | 14 ++++--- a2asrv/reqctx.go | 6 +-- a2asrv/reqmeta.go | 7 ++-- a2asrv/tasks.go | 2 +- 11 files changed, 134 insertions(+), 46 deletions(-) diff --git a/a2asrv/agentcard.go b/a2asrv/agentcard.go index 6a58cc78..e3d379df 100644 --- a/a2asrv/agentcard.go +++ b/a2asrv/agentcard.go @@ -27,11 +27,11 @@ import ( type AgentCardProducer interface { // Card returns a self-describing manifest for an agent. It provides essential // metadata including the agent's identity, capabilities, skills, supported - // communication methods, and security requirements and is publicly available. + // communication methods, and security requirements. Card(ctx context.Context) (*a2a.AgentCard, error) } -// AgentCardProducerFn is a function type which implements AgentCardProducer. +// AgentCardProducerFn is a function type which implements [AgentCardProducer]. type AgentCardProducerFn func(ctx context.Context) (*a2a.AgentCard, error) func (fn AgentCardProducerFn) Card(ctx context.Context) (*a2a.AgentCard, error) { diff --git a/a2asrv/agentexec.go b/a2asrv/agentexec.go index 53ba9cdd..2b328b2a 100644 --- a/a2asrv/agentexec.go +++ b/a2asrv/agentexec.go @@ -24,20 +24,76 @@ import ( ) // AgentExecutor implementations translate agent outputs to A2A events. +// The provided [RequestContext] should be used as a [a2a.TaskInfoProvider] argument for [a2a.Event]-s constructor functions. +// For streaming responses [a2a.TaskArtifactUpdatEvent]-s should be used. +// A2A server stops processing events after one of these events: +// - An [a2a.Message] with any payload. +// - An [a2a.TaskStatusUpdateEvent] with Final field set to true. +// - An [a2a.Task] with a [a2a.TaskState] for which Terminal() method returns true. +// +// The following code can be used as a streaming implementation template with generateOutputs and toParts missing: +// +// func Execute(ctx context.Context, reqCtx *RequestContext, queue eventqueue.Queue) error { +// if reqCtx.StoredTask == nil { +// event := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateSubmitted, nil) +// if err := queue.Write(ctx, event); err != nil { +// return fmt.Errorf("failed to write state submitted: %w", err) +// } +// } +// +// // perform setup +// +// event := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateWorking, nil) +// if err := queue.Write(ctx, event); err != nil { +// return fmt.Errorf("failed to write state working: %w", err) +// } +// +// var artifactID a2a.ArtifactID +// for output, err := range generateOutputs() { +// if err != nil { +// event := a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateFailed, toErrorMessage(err)) +// if err := queue.Write(ctx, event); err != nil { +// return fmt.Errorf("failed to write state failed: %w", err) +// } +// } +// +// parts := toParts(output) +// var event *a2a.TaskArtifactUpdateEvent +// if artifactID == "" { +// event = a2a.NewArtifactEvent(reqCtx, parts...) +// artifactID = event.Artifact.ID +// } else { +// event = a2a.NewArtifactUpdateEvent(reqCtx, artifactID, parts...) +// } +// +// if err := queue.Write(ctx, event); err != nil { +// return fmt.Errorf("failed to write artifact update: %w", err) +// } +// } +// +// event = a2a.NewStatusUpdateEvent(reqCtx, a2a.TaskStateCompleted, nil) +// event.Final = true +// if err := queue.Write(ctx, event); err != nil { +// return fmt.Errorf("failed to write state working: %w", err) +// } +// +// return nil +// } type AgentExecutor interface { - // Execute invokes an agent with the provided context and translates agent outputs - // into A2A events writing them to the provided event queue. + // Execute invokes the agent passing information about the request which triggered execution, + // translates agent outputs to A2A events and writes them to the event queue. + // Every invocation runs in a dedicated goroutine. // - // Returns an error if agent invocation failed. + // Failures should generally be reported by writing events carrying the cancelation information + // and task state. An error should be returned in special cases like a failure to write an event. Execute(ctx context.Context, reqCtx *RequestContext, queue eventqueue.Queue) error - // Cancel requests the agent to stop processing an ongoing task. - // - // The agent should attempt to gracefully stop the task identified by the - // task ID in the request context and publish a TaskStatusUpdateEvent with - // state TaskStateCanceled to the event queue. + // Cancel is called when a client requests the agent to stop working on a task. + // The simplest implementation can write a cancelation event to the queue and let + // it be processed by the A2A server. If the events gets applied during an active execution the execution + // Context gets canceled. // - // Returns an error if the cancelation request cannot be processed. + // An an error should be returned if the cancelation request cannot be processed or a queue write failed. Cancel(ctx context.Context, reqCtx *RequestContext, queue eventqueue.Queue) error } diff --git a/a2asrv/auth.go b/a2asrv/auth.go index 6293bd90..93796fd2 100644 --- a/a2asrv/auth.go +++ b/a2asrv/auth.go @@ -14,7 +14,7 @@ package a2asrv -// User can be attached to call context by authentication middleware. +// User can be attached to [CallContext] by authentication middleware. type User interface { // Name returns a username. Name() string @@ -22,7 +22,7 @@ type User interface { Authenticated() bool } -// AuthenticatedUser is a simple implementation of User interface which can be configured with a username. +// AuthenticatedUser is a simple implementation of [User] interface configurable with a username. type AuthenticatedUser struct { UserName string } diff --git a/a2asrv/doc.go b/a2asrv/doc.go index f9143bc8..1dbc83a5 100644 --- a/a2asrv/doc.go +++ b/a2asrv/doc.go @@ -12,9 +12,38 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package a2asrv provides a configurable A2A protocol server implementation. -// -// The server can be configured with custom implementations of core interfaces -// like TaskStore, AgentExecutor, and PushSender to support different -// deployment scenarios and business requirements. +/* +Package a2asrv provides a configurable A2A protocol server implementation. + +The default implementation can be created using NewRequestHandler. The function takes a single required +[AgentExecutor] dependency and a variable number of [RequestHandlerOption]-s used to customize handler behavior. + +AgentExecutor implementation is responsible for invoking the agent, translating its outputs +to a2a core types and writing them to the provided [eventqueue.Queue]. A2A server will be reading +data from the queue, processing it and notifying connected clients. + +RequestHandler is transport-agnostic and needs to be wrapped in a transport-specific translation layer +like [github.com/a2aproject/a2a-go/a2agrpc.Handler]. JSONRPC transport implementation can be created using NewJSONRPCHandler function +and registered with the standard [http.Server]: + + handler := a2asrv.NewRequestHandler( + agentExecutor, + a2asrv.WithTaskStore(customDB), + a2asrv.WithPushNotifications(configStore, sender), + a2asrv.WithCallInterceptor(customMiddleware), + ... + ) + + mux := http.NewServeMux() + mux.Handle("/invoke", a2asrv.NewJSONRPCHandler(handler)) + +The package provides utilities for serving public [a2a.AgentCard]-s. These return handler implementations +which can be registered with a standard http server. Since the card is public, CORS policy allows requests from any domain. + + mux.Handle(a2asrv, a2asrv.NewStaticAgentCardHandler(card)) + + // or for more advanced use cases + + mux.Handle(a2asrv, a2asrv.NewAgentCardHandler(producer)) +*/ package a2asrv diff --git a/a2asrv/extensions.go b/a2asrv/extensions.go index cff835ee..2b7248dd 100644 --- a/a2asrv/extensions.go +++ b/a2asrv/extensions.go @@ -21,6 +21,7 @@ import ( "github.com/a2aproject/a2a-go/a2a" ) +// ExtensionsMetaKey is the default extensions key for extensions metadata passed with a request or in a response. const ExtensionsMetaKey = "X-A2A-Extensions" // Extensions provides utility methods for accessing extensions requested by the client and keeping track of extensions @@ -52,7 +53,7 @@ func (e *Extensions) Activate(extension *a2a.AgentExtension) { e.callCtx.activatedExtensions = append(e.callCtx.activatedExtensions, extension.URI) } -// ActivatedURIs returns all URIs activated during call execution. +// ActivatedURIs returns URIs of all extensions activated during call processing. func (e *Extensions) ActivatedURIs() []string { return slices.Clone(e.callCtx.activatedExtensions) } @@ -62,7 +63,7 @@ func (e *Extensions) Requested(extension *a2a.AgentExtension) bool { return slices.Contains(e.RequestedURIs(), extension.URI) } -// RequestedURIs returns all URIs of extensions requested by the client. +// RequestedURIs returns URIs all of all extensions requested by the client. func (e *Extensions) RequestedURIs() []string { requested, ok := e.callCtx.RequestMeta().Get(ExtensionsMetaKey) if !ok { diff --git a/a2asrv/handler.go b/a2asrv/handler.go index 1527fcf2..dd04cee5 100644 --- a/a2asrv/handler.go +++ b/a2asrv/handler.go @@ -56,7 +56,7 @@ type RequestHandler interface { // OnDeleteTaskPushConfig handles the `tasks/pushNotificationConfig/delete` protocol method. OnDeleteTaskPushConfig(ctx context.Context, params *a2a.DeleteTaskPushConfigParams) error - // GetAgentCard returns an extended [a2a.AgentCard] if configured. + // GetAgentCard returns an extended a2a.AgentCard if configured. OnGetExtendedAgentCard(ctx context.Context) (*a2a.AgentCard, error) } @@ -75,21 +75,21 @@ type defaultRequestHandler struct { authenticatedCardProducer AgentCardProducer } +// RequestHandlerOption can be used to customize the default [RequestHandler] implementation behavior. type RequestHandlerOption func(*InterceptedHandler, *defaultRequestHandler) -type HTTPPushConfig push.HTTPSenderConfig - -// WithTaskStore overrides TaskStore with custom implementation. +// WithTaskStore overrides TaskStore with a custom implementation. If not provided, +// default to an in-memory implementation. func WithTaskStore(store TaskStore) RequestHandlerOption { return func(ih *InterceptedHandler, h *defaultRequestHandler) { h.taskStore = store } } -// WithLogger sets a custom [slog.Logger]. Request scoped parameters will be attached to this logger -// on method invocations. Any injected dependency will be able to access the logger using either -// github.com/a2aproject/a2a-go/log package-level functions. -// If not provided, defaults to slog.Defadult(). +// WithLogger sets a custom logger. Request scoped parameters will be attached to this logger +// on method invocations. Any injected dependency will be able to access the logger using +// [github.com/a2aproject/a2a-go/log] package-level functions. +// If not provided, defaults to slog.Default(). func WithLogger(logger *slog.Logger) RequestHandlerOption { return func(ih *InterceptedHandler, h *defaultRequestHandler) { ih.Logger = logger @@ -103,7 +103,8 @@ func WithEventQueueManager(manager eventqueue.Manager) RequestHandlerOption { } } -// WithPushNotifications adds support for push notifications. +// WithPushNotifications adds support for push notifications. If dependencies are not provided +// push-related methods will be returning a2a.ErrPushNotificationNotSupported, func WithPushNotifications(store PushConfigStore, notifier PushSender) RequestHandlerOption { return func(ih *InterceptedHandler, h *defaultRequestHandler) { h.pushConfigStore = store @@ -111,7 +112,7 @@ func WithPushNotifications(store PushConfigStore, notifier PushSender) RequestHa } } -// WithRequestContextInterceptor overrides default RequestContextInterceptor with custom implementation. +// WithRequestContextInterceptor overrides the default RequestContextInterceptor with a custom implementation. func WithRequestContextInterceptor(interceptor RequestContextInterceptor) RequestHandlerOption { return func(ih *InterceptedHandler, h *defaultRequestHandler) { h.reqContextInterceptors = append(h.reqContextInterceptors, interceptor) diff --git a/a2asrv/intercepted_handler.go b/a2asrv/intercepted_handler.go index bf1f80a2..70471795 100644 --- a/a2asrv/intercepted_handler.go +++ b/a2asrv/intercepted_handler.go @@ -25,14 +25,14 @@ import ( "github.com/a2aproject/a2a-go/log" ) -// InterceptedHandler implements RequestHandler. It can be used to attach call interceptors and initialize +// InterceptedHandler implements [RequestHandler]. It can be used to attach call interceptors and initialize // call context for every method of the wrapped handler. type InterceptedHandler struct { // Handler is responsible for the actual processing of every call. Handler RequestHandler // Interceptors is a list of call interceptors which will be applied before and after each call. Interceptors []CallInterceptor - // Logger is the logger which will be accessible from request scope context using [github.com/a2aproject/a2a-go/a2a/log] package + // Logger is the logger which will be accessible from request scope context using log package // methods. Defaults to slog.Default() if not set. Logger *slog.Logger } diff --git a/a2asrv/middleware.go b/a2asrv/middleware.go index 47caa61a..e4183a2c 100644 --- a/a2asrv/middleware.go +++ b/a2asrv/middleware.go @@ -27,9 +27,9 @@ func CallContextFrom(ctx context.Context) (*CallContext, bool) { return callCtx, ok } -// WithCallContext can be called by a transport implementation to provide request metadata to RequestHandler +// WithCallContext can be called by a transport implementation to provide request metadata to [RequestHandler] // or to have access to the list of activated extensions after the call ends. -// If context already had a CallContext attached it will be shadowed. +// If context already had a [CallContext] attached, the old context will be shadowed. func WithCallContext(ctx context.Context, meta *RequestMeta) (context.Context, *CallContext) { callCtx := &CallContext{User: unauthenticatedUser{}, requestMeta: meta} return context.WithValue(ctx, callContextKey{}, callCtx), callCtx @@ -62,19 +62,21 @@ func (cc *CallContext) Extensions() *Extensions { } // Request represents a transport-agnostic request received by the A2A server. -// Payload is one of a2a package core types. type Request struct { + // Payload is one of a2a package core types. It is nil when a request does not have any parameters. Payload any } // Response represents a transport-agnostic response generated by the A2A server. // Payload is one of a2a package core types. type Response struct { + // Payload is one of a2a package core types. It is nil when Err is set or when a request does not return any value. Payload any - Err error + // Err is set to indicate that request processing failed. + Err error } -// CallInterceptor can be attached to an a2asrv.RequestHandler. If multiple interceptors are added: +// CallInterceptor can be attached to an [RequestHandler]. If multiple interceptors are added: // - Before will be executed in the order of attachment sequentially. // - After will be executed in the reverse order sequentially. type CallInterceptor interface { @@ -86,7 +88,7 @@ type CallInterceptor interface { After(ctx context.Context, callCtx *CallContext, resp *Response) error } -// PassthroughInterceptor can be used by CallInterceptor implementers who don't need all methods. +// PassthroughInterceptor can be used by [CallInterceptor] implementers who don't need all methods. // The struct can be embedded for providing a no-op implementation. type PassthroughCallInterceptor struct{} diff --git a/a2asrv/reqctx.go b/a2asrv/reqctx.go index a93aee64..c5c79850 100644 --- a/a2asrv/reqctx.go +++ b/a2asrv/reqctx.go @@ -22,13 +22,13 @@ import ( ) // RequestContextInterceptor defines an extension point for modifying request contexts -// that contain the information needed by AgentExecutor implementations to process incoming requests. +// that contain the information needed by [AgentExecutor] implementations to process incoming requests. type RequestContextInterceptor interface { - // Intercept has a chance to modify a RequestContext before it gets passed to AgentExecutor.Execute. + // Intercept has a chance to modify a RequestContext before it gets passed to AgentExecutor. Intercept(ctx context.Context, reqCtx *RequestContext) (context.Context, error) } -// RequestContext provides information about an incoming A2A request to AgentExecutor. +// RequestContext provides information about an incoming A2A request to [AgentExecutor]. type RequestContext struct { // A message which triggered the execution. nil for cancelation request. Message *a2a.Message diff --git a/a2asrv/reqmeta.go b/a2asrv/reqmeta.go index ffe64596..c74688fa 100644 --- a/a2asrv/reqmeta.go +++ b/a2asrv/reqmeta.go @@ -19,14 +19,13 @@ import ( "strings" ) -// RequestMeta holds the data like auth headers, signatures, etc. -// Custom transport implementations pass the values to WithCallContext to make it accessible -// in a transport-agnostic way. +// RequestMeta holds the metadata associated with a request, like auth headers and signatures. +// Custom transport implementations can call WithCallContext to make it accessible during request processing. type RequestMeta struct { kv map[string][]string } -// NewRequestMeta creates a new immutable RequestMeta. +// NewRequestMeta is a [RequestMeta] constructor function . func NewRequestMeta(src map[string][]string) *RequestMeta { if src == nil { return &RequestMeta{kv: map[string][]string{}} diff --git a/a2asrv/tasks.go b/a2asrv/tasks.go index f586e3e9..834fa019 100644 --- a/a2asrv/tasks.go +++ b/a2asrv/tasks.go @@ -47,7 +47,7 @@ type PushConfigStore interface { DeleteAll(ctx context.Context, taskID a2a.TaskID) error } -// TaskStore provides storage for A2A tasks. +// TaskStore provides storage for [a2a.Task]-s. type TaskStore interface { // Save stores a task. Save(ctx context.Context, task *a2a.Task) error From a602500a474648f2451fd02a10413b31b9eb8169 Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Fri, 31 Oct 2025 14:09:19 +0000 Subject: [PATCH 11/15] a2asrv nested packages --- a2asrv/eventqueue/queue.go | 4 ++-- a2asrv/handler.go | 4 ++-- a2asrv/push/doc.go | 9 +++++++++ a2asrv/push/sender.go | 2 +- a2asrv/push/store.go | 2 +- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/a2asrv/eventqueue/queue.go b/a2asrv/eventqueue/queue.go index eb3848c3..3aeaa573 100644 --- a/a2asrv/eventqueue/queue.go +++ b/a2asrv/eventqueue/queue.go @@ -27,14 +27,14 @@ var ( ) // Reader defines the interface for reading events from a queue. -// A2A server stack reads events written by AgentExecutor. +// A2A server stack reads events written by [a2asrv.AgentExecutor]. type Reader interface { // Read dequeues an event or blocks if the queue is empty. Read(ctx context.Context) (a2a.Event, error) } // Writer defines the interface for writing events to a queue. -// AgentExecutor translates agent responses to Messages, Tasks or Task update events. +// [a2asrv.AgentExecutor] translates agent responses to Messages, Tasks or Task update events. type Writer interface { // Write enqueues an event or blocks if a bounded queue is full. Write(ctx context.Context, event a2a.Event) error diff --git a/a2asrv/handler.go b/a2asrv/handler.go index dd04cee5..cd77910c 100644 --- a/a2asrv/handler.go +++ b/a2asrv/handler.go @@ -105,10 +105,10 @@ func WithEventQueueManager(manager eventqueue.Manager) RequestHandlerOption { // WithPushNotifications adds support for push notifications. If dependencies are not provided // push-related methods will be returning a2a.ErrPushNotificationNotSupported, -func WithPushNotifications(store PushConfigStore, notifier PushSender) RequestHandlerOption { +func WithPushNotifications(store PushConfigStore, sender PushSender) RequestHandlerOption { return func(ih *InterceptedHandler, h *defaultRequestHandler) { h.pushConfigStore = store - h.pushSender = notifier + h.pushSender = sender } } diff --git a/a2asrv/push/doc.go b/a2asrv/push/doc.go index 8f3710c4..d977f608 100644 --- a/a2asrv/push/doc.go +++ b/a2asrv/push/doc.go @@ -13,4 +13,13 @@ // limitations under the License. // Package push provides a basic implementation of push notification functionality. +// To enable push notifications in the default server implementation, [github.com/a2aproject/a2a-go/a2asrv.WithPushNotifications] function +// should be used: +// +// sender := push.NewHTTPPushSender() +// configStore := push.NewInMemoryStore() +// requestHandler := a2asrv.NewRequestHandler( +// agentExecutor, +// a2asrv.WithPushNotifications(configStore, sender), +// ) package push diff --git a/a2asrv/push/sender.go b/a2asrv/push/sender.go index 35629674..d8b44ec4 100644 --- a/a2asrv/push/sender.go +++ b/a2asrv/push/sender.go @@ -35,7 +35,7 @@ type HTTPPushSender struct { failOnError bool } -// HTTPSenderConfig allows to adjust HTTPPushSender +// HTTPSenderConfig allows to configure [HTTPPushSender]. type HTTPSenderConfig struct { // Timeout is used to configure internal [http.Client]. Timeout time.Duration diff --git a/a2asrv/push/store.go b/a2asrv/push/store.go index 862f05f5..b3d92bf1 100644 --- a/a2asrv/push/store.go +++ b/a2asrv/push/store.go @@ -26,7 +26,7 @@ import ( "github.com/google/uuid" ) -// ErrPushConfigNotFound indicates that a push config with the provided ID was not found +// ErrPushConfigNotFound indicates that a push config with the provided ID was not found. var ErrPushConfigNotFound = errors.New("push config not found") // InMemoryPushConfigStore implements a2asrv.PushConfigStore. From ca943071592209b77de49a027a3f5ef1c852eae8 Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Fri, 31 Oct 2025 14:21:15 +0000 Subject: [PATCH 12/15] a2aclient auth --- a2aclient/agentcard/resolver.go | 4 ++-- a2aclient/client.go | 2 +- a2aclient/doc.go | 24 +++++++++++++++++++----- a2aclient/factory.go | 4 ++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/a2aclient/agentcard/resolver.go b/a2aclient/agentcard/resolver.go index 68999873..953a6462 100644 --- a/a2aclient/agentcard/resolver.go +++ b/a2aclient/agentcard/resolver.go @@ -39,9 +39,9 @@ func (e *ErrStatusNotOK) Error() string { const defaultAgentCardPath = "/.well-known/agent-card.json" -var defaultClient = &http.Client{Timeout: 15 * time.Second} +var defaultClient = &http.Client{Timeout: 30 * time.Second} -// DefaultResolver is configured with an [http.Client] with a 15 seconds timeout. +// DefaultResolver is configured with an [http.Client] with a 30-second timeout. var DefaultResolver = &Resolver{Client: defaultClient} // Resolver is used to fetch an [a2a.AgentCard]. diff --git a/a2aclient/client.go b/a2aclient/client.go index 9d5078e8..94786cb7 100644 --- a/a2aclient/client.go +++ b/a2aclient/client.go @@ -42,7 +42,7 @@ type Config struct { // Client represents a transport-agnostic implementation of A2A client. // The actual call is delegated to a specific [Transport] implementation. -// [CallInterceptor]s are applied before and after every protocol call. +// [CallInterceptor]-s are applied before and after every protocol call. type Client struct { config Config transport Transport diff --git a/a2aclient/doc.go b/a2aclient/doc.go index b223c284..d311e569 100644 --- a/a2aclient/doc.go +++ b/a2aclient/doc.go @@ -16,9 +16,7 @@ Package a2aclient provides a transport-agnostic A2A client implementation. Under the hood it handles transport protocol negotiation and connection establishment. -A [Client] can be configured with [CallInterceptor] middleware and custom -transports. - +A [Client] can be configured with [CallInterceptor] middleware and custom transports. If a client is created in multiple places, a [Factory] can be used to share the common configuration options: factory := NewFactory( @@ -28,16 +26,32 @@ If a client is created in multiple places, a [Factory] can be used to share the ) A client can be created from an [a2a.AgentCard] or a list of known [a2a.AgentInterface] descriptions -using either package-level functions or factory methods. +using either package-level functions or [Factory] methods. client, err := factory.CreateFromEndpoints(ctx, []a2a.AgentInterface{URL: url, Transport: a2a.TransportProtocolGRPC}) // or - card, err := agentcard.Fetch(ctx, url) + card, err := agentcard.DefaultResolved.Resolve(ctx, url) if err != nil { log.Fatalf("Failed to resolve an AgentCard: %v", err) } client, err := a2aclient.NewFromCard(ctx, card, WithInterceptors(&customInterceptor{})) + +An [AuthInterceptor] provides a basic support for attaching credentials listed as security requirements in agent card to requests. +Credentials retrieval logic is application specific and is not handled by the package. + + // client setup + store := a2aclient.InMemoryCredentialsStore() + interceptors := WithInterceptors(&a2aclient.AuthInterceptor{Service: store}) + client, err := a2aclient.NewFromCard(ctx, card, interceptors) + + // session setup + sessionID := newSessionID() + store.Set(sessionID, a2a.SecuritySchemeName("..."), credential) + sessionCtx := a2aclient.WithSessionID(ctx, sessionID) + + // credentials will be automatically attached to requests if listed as security requirements + resp, err := client.SendMessage(sessionCtx, params) */ package a2aclient diff --git a/a2aclient/factory.go b/a2aclient/factory.go index a97ef5d9..f3fc8258 100644 --- a/a2aclient/factory.go +++ b/a2aclient/factory.go @@ -66,8 +66,10 @@ func NewFromEndpoints(ctx context.Context, endpoints []a2a.AgentInterface, opts // CreateFromCard returns a [Client] configured to communicate with the agent described by // the provided [a2a.AgentCard] or fails if we couldn't establish a compatible transport. // [Config].PreferredTransports field is used to determine the order of connection attempts. +// // If PreferredTransports were not provided, we start from the PreferredTransport specified in the AgentCard // and proceed in the order specified by the AdditionalInterfaces. +// // The method fails if we couldn't establish a compatible transport. func (f *Factory) CreateFromCard(ctx context.Context, card *a2a.AgentCard) (*Client, error) { serverPrefs := make([]a2a.AgentInterface, 1+len(card.AdditionalInterfaces)) @@ -96,7 +98,9 @@ func (f *Factory) CreateFromCard(ctx context.Context, card *a2a.AgentCard) (*Cli // CreateFromEndpoints returns a [Client] configured to communicate with one of the provided endpoints. // [Config].PreferredTransports field is used to determine the order of connection attempts. +// // If PreferredTransports were not provided, we attempt to establish a connection using the provided endpoint order. +// // The method fails if we couldn't establish a compatible transport. func (f *Factory) CreateFromEndpoints(ctx context.Context, endpoints []a2a.AgentInterface) (*Client, error) { candidates, err := f.selectTransport(endpoints) From 1799187d9306ea1367f37d663a4af837a9740e46 Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Fri, 31 Oct 2025 14:24:34 +0000 Subject: [PATCH 13/15] fix go-get --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e59733c..23ea92fb 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Requires Go `1.24.4` or newer: ```bash -go get github.com/a2aproject/a2a-go@v0.3.0 +go get github.com/a2aproject/a2a-go@0.3.0 ``` Visit [**pkg.go**](https://pkg.go.dev/github.com/a2aproject/a2a-go) for a full documentation. From d6b4b9666cf95dd26bc018d26e2c63da4370fc4c Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Tue, 4 Nov 2025 07:55:31 +0000 Subject: [PATCH 14/15] jsonrpc server example --- examples/helloworld/server/grpc/main.go | 2 +- examples/helloworld/server/jsonrpc/main.go | 86 ++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 examples/helloworld/server/jsonrpc/main.go diff --git a/examples/helloworld/server/grpc/main.go b/examples/helloworld/server/grpc/main.go index 9c97b8f7..11c6b4f8 100644 --- a/examples/helloworld/server/grpc/main.go +++ b/examples/helloworld/server/grpc/main.go @@ -72,7 +72,7 @@ func servePublicCard(port int, card *a2a.AgentCard) error { log.Printf("Starting a public AgentCard server on 127.0.0.1:%d", port) mux := http.NewServeMux() - mux.Handle("/.well-known/agent-card.json", a2asrv.NewStaticAgentCardHandler(card)) + mux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(card)) return http.Serve(listener, mux) } diff --git a/examples/helloworld/server/jsonrpc/main.go b/examples/helloworld/server/jsonrpc/main.go new file mode 100644 index 00000000..f15fb1e5 --- /dev/null +++ b/examples/helloworld/server/jsonrpc/main.go @@ -0,0 +1,86 @@ +// Copyright 2025 The A2A Authors +// +// 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 +// +// http://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. + +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net" + "net/http" + + "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/a2asrv/eventqueue" +) + +// agentExecutor implements [a2asrv.AgentExecutor], which is a required [a2asrv.RequestHandler] dependency. +// It is responsible for invoking an agent, translating its outputs to a2a.Event object and writing them to the provided [eventqueue.Queue]. +type agentExecutor struct{} + +func (*agentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestContext, q eventqueue.Queue) error { + response := a2a.NewMessage(a2a.MessageRoleAgent, a2a.TextPart{Text: "Hello, world!"}) + return q.Write(ctx, response) +} + +func (*agentExecutor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestContext, q eventqueue.Queue) error { + return nil +} + +var ( + port = flag.Int("port", 9001, "Port for a gGRPC A2A server to listen on.") +) + +func main() { + flag.Parse() + + agentCard := &a2a.AgentCard{ + Name: "Hello World Agent", + Description: "Just a hello world agent", + URL: fmt.Sprintf("http://127.0.0.1:%d/invoke", *port), + PreferredTransport: a2a.TransportProtocolJSONRPC, + DefaultInputModes: []string{"text"}, + DefaultOutputModes: []string{"text"}, + Capabilities: a2a.AgentCapabilities{Streaming: true}, + Skills: []a2a.AgentSkill{ + { + ID: "hello_world", + Name: "Hello, world!", + Description: "Returns a 'Hello, world!'", + Tags: []string{"hello world"}, + Examples: []string{"hi", "hello"}, + }, + }, + } + + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", *port)) + if err != nil { + log.Fatalf("Failed to bind to a port: %v", err) + } + log.Printf("Starting a JSONRPC server on 127.0.0.1:%d", *port) + + // A transport-agnostic implementation of A2A protocol methods. + // The behavior is configurable using option-arguments of form a2asrv.With*(), for example: + // a2asrv.NewHandler(executor, a2asrv.WithTaskStore(customStore)) + requestHandler := a2asrv.NewHandler(&agentExecutor{}) + + mux := http.NewServeMux() + mux.Handle("/invoke", a2asrv.NewJSONRPCHandler(requestHandler)) + mux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(agentCard)) + + err = http.Serve(listener, mux) + log.Printf("Server stopped: %v", err) +} From 86460c49a827a8ad2b971180f924f7f734151487 Mon Sep 17 00:00:00 2001 From: Yaroslav Shevchuk Date: Tue, 4 Nov 2025 07:59:57 +0000 Subject: [PATCH 15/15] update readme --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 23ea92fb..650d011e 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Visit [**pkg.go**](https://pkg.go.dev/github.com/a2aproject/a2a-go) for a full d ## Examples -For a simple example refer to [gRPC helloworld](./examples/grpc/helloworld). +For a simple example refer to the [helloworld] example(./examples/helloworld). ### Server @@ -78,8 +78,7 @@ For a full documentation visit [**pkg.go.dev/a2aclient**](https://pkg.go.dev/git 1. Resolve an `AgentCard` to get an information about how an agent is exposed. ```go - cardResolver := agentcard.Resolver{BaseURL: *cardURL} - card, err := cardResolver.Resolve(ctx) + card, err := agentcard.DefaultResolver.Resolve(ctx) ``` 2. Create a transport-agnostic client from the `AgentCard`: @@ -108,6 +107,8 @@ You can find a variety of more detailed examples in the [a2a-samples](https://gi Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to get involved. +Before starting work on a new feature or significant change, please open an issue to discuss your proposed approach with the maintainers. This helps ensure your contribution aligns with the project's goals and prevents duplicated effort or wasted work + --- ## ๐Ÿ“„ License